Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 580b1ea7a7 | |||
| 831b37b4b7 | |||
| 4f0b4aa68f | |||
| 963c406f33 | |||
| 7ab8acaf55 | |||
| c8e19396e4 | |||
| 78020cd66c | |||
| 8870e85233 | |||
| a81f1304cd | |||
| c75f39fd8e | |||
| 37c3e2bed2 | |||
| 4f40fa3cb7 | |||
| 486a292845 | |||
| d8c254281e | |||
| ae71e4628d | |||
| 01c55aed38 | |||
| 95308d352b | |||
| 9ff7d41bfe | |||
| 2b47d8cc28 | |||
| 7cd9364c9e | |||
| 3e498cd40d | |||
| 60de506f66 | |||
| 2865368771 | |||
| 6e2e946bc9 | |||
| b5f02000d6 | |||
| fe562f0562 | |||
| 4e00037a89 | |||
| 46b9aa8cf0 | |||
| 372b27540b | |||
| db4d15bf8b | |||
| 8e24ea9ed7 | |||
| 73c0125843 |
+20
-6
@@ -13,11 +13,25 @@
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# rsa "Marvin Attack" — a timing sidechannel in RSA *decryption* (PKCS#1 v1.5 padding oracle).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream), and rsa
|
||||
# is required for GameStream/Moonlight pairing. Crucially, the host uses rsa ONLY for PKCS#1 v1.5
|
||||
# SIGNING / VERIFYING (gamestream/cert.rs + gamestream/pairing.rs: SigningKey / VerifyingKey /
|
||||
# Signer / Verifier) — it never performs RSA decryption, which is the operation Marvin targets.
|
||||
# So the vulnerable code path is not exercised. Revisit if a fixed rsa ships or we add RSA decrypt.
|
||||
# rsa "Marvin Attack" (RUSTSEC-2023-0071): a timing side-channel in the rsa crate's variable-time
|
||||
# modular exponentiation of the SECRET exponent. IMPORTANT — this affects the RSA private-key op in
|
||||
# general, INCLUDING signing (m^d mod n), which the host DOES perform (gamestream/pairing.rs
|
||||
# `signing_key.sign(&serversecret)`). It is NOT, as an earlier version of this note wrongly claimed,
|
||||
# limited to decryption — so "the vulnerable path isn't exercised" is false; signing exercises it.
|
||||
# We accept it because the attack is not practically reachable here, NOT because the path is unused:
|
||||
# * No RSA decryption / PKCS#1v1.5 padding oracle exists anywhere (every `decrypt` in the tree is
|
||||
# AES/AES-GCM), so the classic Bleichenbacher/Marvin chosen-ciphertext oracle is absent.
|
||||
# * The only signed message (`serversecret`) is HOST-generated random, never attacker-chosen — so
|
||||
# there's no adaptive chosen-input probing (the lever remote RSA-timing key recovery needs); and
|
||||
# signing is gated behind the operator-entered pairing PIN, ONE signature per ceremony (a
|
||||
# repeated phase-3 is rejected — gamestream/pairing.rs — to deny a passive timing-sample harvester).
|
||||
# * GameStream is OFF by default (bare `serve` is native-only); the secure native QUIC plane uses
|
||||
# rustls' constant-time backend, NOT the rsa crate. RSA is touched only on the opt-in,
|
||||
# trusted-LAN GameStream/Moonlight pairing handshake. Moonlight mandates RSA-2048, so the
|
||||
# GameStream identity cannot move to Ed25519/ECDSA (only the native identity could, and it
|
||||
# already avoids the rsa crate).
|
||||
# There is NO fixed rsa release (the constant-time rewrite is still unreleased upstream). Revisit if:
|
||||
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||
"RUSTSEC-2023-0071",
|
||||
]
|
||||
|
||||
@@ -207,10 +207,20 @@ jobs:
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a profile-signed
|
||||
# archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager
|
||||
# ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates
|
||||
# (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile.
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
@@ -218,11 +228,10 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="$PROFILE"
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -252,35 +261,27 @@ jobs:
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# MANUAL App Store signing: the local (valid) Apple Distribution identity + the App
|
||||
# Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role
|
||||
# ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud
|
||||
# signing permission error"). The profile must be installed on the runner under
|
||||
# ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with
|
||||
# Xcode.app quit, or it prunes the manually-dropped distribution profile).
|
||||
# A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App
|
||||
# Store profile survives this build; headless xcodebuild doesn't need the GUI app.
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
||||
# manually-installed App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the
|
||||
# full rationale. A global (CLI) profile specifier would also be forced onto the shared
|
||||
# macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*]
|
||||
# in an xcconfig lands it on the app/framework slices only.
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -312,33 +313,24 @@ jobs:
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign).
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
# Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier
|
||||
# hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) which build for the macOS host and reject a provisioning profile
|
||||
# ("<macro> does not support provisioning profiles"), failing the archive. Conditionals
|
||||
# work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a
|
||||
# command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on
|
||||
# the app/framework slices only — the macosx-host macros get nothing. (The macOS archive
|
||||
# above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier
|
||||
# is ignored there.)
|
||||
SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig"
|
||||
cat > "$SIGN_XCCONFIG" <<XCCONF
|
||||
CODE_SIGN_STYLE = Manual
|
||||
DEVELOPMENT_TEAM = $TEAM_ID
|
||||
CODE_SIGN_IDENTITY[sdk=appletvos*] = Apple Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER[sdk=appletvos*] = $PROFILE
|
||||
XCCONF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-xcconfig "$SIGN_XCCONFIG" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM"
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
Generated
+24
@@ -2331,6 +2331,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -2839,12 +2850,14 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"usbip-sim",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
"wasapi",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
@@ -4236,6 +4249,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
@@ -11,6 +12,8 @@ members = [
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
# Standalone PoC (built on its own; pulls usbip/tokio/libusb we don't want in the workspace).
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
|
||||
+9
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
"version": "0.3.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1354,6 +1354,14 @@
|
||||
"type": "object",
|
||||
"description": "Arm-native-pairing request body.",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"ttl_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
|
||||
@@ -50,15 +50,25 @@ object Gamepad {
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
const val PREF_STEAMCONTROLLER = 5
|
||||
const val PREF_STEAMDECK = 6
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
private const val VID_VALVE = 0x28DE
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// 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 /
|
||||
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||
// buttons + sticks reach the host for now — parity with the desktop type resolution.
|
||||
private val PID_STEAMDECK = setOf(0x1205)
|
||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
@@ -82,6 +92,8 @@ object Gamepad {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -24,6 +24,9 @@ let package = Package(
|
||||
.copy("Resources/THIRD-PARTY-NOTICES.txt"),
|
||||
.copy("Resources/LICENSE-MIT.txt"),
|
||||
.copy("Resources/LICENSE-APACHE.txt"),
|
||||
// Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website.
|
||||
// Registered with Core Text at first use; see BrandFont.swift.
|
||||
.copy("Resources/Fonts"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -398,7 +398,7 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -429,7 +429,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -468,7 +468,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -506,7 +506,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -536,7 +536,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunk";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
@@ -10,32 +10,59 @@ struct AcknowledgementsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.title2).bold()
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.caption)
|
||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||
// ~885 KB total) load lazily as they scroll into view — a single Text that large overshoots
|
||||
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
|
||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Licenses.thirdPartyNotices)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
|
||||
Text(Licenses.thirdPartyNoticesChunks[i])
|
||||
.font(.caption2.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -81,6 +81,11 @@ struct AddHostSheet: View {
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The detent below is sized to fit all 3 rows + the action button exactly, so the
|
||||
// Form must NOT scroll/bounce inside it — lock it. (iOS 16+; safe at iOS 17.)
|
||||
.scrollDisabled(true)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// macOS: UNCHANGED — Cancel + Spacer + Add in an HStack, both wired to the
|
||||
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
|
||||
@@ -120,8 +125,8 @@ struct AddHostSheet: View {
|
||||
// Form + the full-width action row, instead of the half-screen .medium it used to rest
|
||||
// at. A single fixed detent is enough: the system keeps the content above the keyboard
|
||||
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
|
||||
// centered formSheet card). If Dynamic Type grows the rows past this height the Form just
|
||||
// scrolls inside the detent — nothing is clipped. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
|
||||
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
|
||||
.presentationDetents([.height(320)])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
|
||||
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
|
||||
// (set once at launch). Backgrounds are left at the system defaults — transparent at the scroll
|
||||
// edge (the large title floats on the content), blurred once scrolled — so only the typeface
|
||||
// changes: Geist, matching the cards and the website.
|
||||
|
||||
#if os(iOS)
|
||||
import PunktfunkKit
|
||||
import UIKit
|
||||
|
||||
enum BrandTheme {
|
||||
static func apply() {
|
||||
BrandFont.registerIfNeeded()
|
||||
|
||||
let scrollEdge = UINavigationBarAppearance()
|
||||
scrollEdge.configureWithTransparentBackground()
|
||||
applyFonts(to: scrollEdge)
|
||||
|
||||
let standard = UINavigationBarAppearance()
|
||||
standard.configureWithDefaultBackground()
|
||||
applyFonts(to: standard)
|
||||
|
||||
let proxy = UINavigationBar.appearance()
|
||||
proxy.scrollEdgeAppearance = scrollEdge
|
||||
proxy.standardAppearance = standard
|
||||
proxy.compactAppearance = standard
|
||||
}
|
||||
|
||||
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
|
||||
private static func applyFonts(to appearance: UINavigationBarAppearance) {
|
||||
if let large = UIFont(name: "Geist-Bold", size: 34) {
|
||||
appearance.largeTitleTextAttributes[.font] = large
|
||||
}
|
||||
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
|
||||
appearance.titleTextAttributes[.font] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +28,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@@ -68,15 +69,19 @@ struct ContentView: View {
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
// plus the accent ring on the most recent host).
|
||||
guard let host = model.activeHost else { break }
|
||||
store.markConnected(host.id)
|
||||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||
// host's observed fingerprint and remember it as paired — future connects are then
|
||||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||
if awaitingApproval?.host.id == host.id {
|
||||
if let fp = model.connection?.hostFingerprint {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
awaitingApproval = nil
|
||||
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||||
? model.connection?.hostFingerprint : nil
|
||||
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||
@@ -333,6 +338,7 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
@@ -475,6 +481,7 @@ struct ContentView: View {
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||
// two-column layout.
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
SettingsView()
|
||||
.settingsSheetSizing()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
@@ -249,8 +248,10 @@ struct HomeView: View {
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||
// it needs room for a monospaced "IP:port" without truncating.
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let iconSize: CGFloat
|
||||
let iconBox: CGFloat
|
||||
let cardPadding: CGFloat
|
||||
let nameFont: Font
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||
Circle()
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||
Text(host.displayName)
|
||||
.font(m.nameFont)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if host.pinnedSHA256 != nil {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let last = host.lastConnected {
|
||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(m.nameFont)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -146,7 +146,7 @@ private struct GameCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { storeBadge }
|
||||
Text(game.title)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||
// screenshot harness too, so captured screens are on-brand.
|
||||
Group {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
.tint(.brand)
|
||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||
.font(.geist(17, relativeTo: .body))
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
// A separate scene — `.tint` does not cross scene boundaries, so re-apply the brand
|
||||
// tint here or the Preferences window falls back to the (unreliable) asset accent.
|
||||
SettingsView()
|
||||
.tint(.brand)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
||||
// shared across all three so a setting is defined exactly once.
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -21,7 +23,8 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@@ -32,6 +35,21 @@ struct SettingsView: View {
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var settingsSelection: SettingsCategory?
|
||||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State private var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -39,6 +57,15 @@ struct SettingsView: View {
|
||||
@State private var inputDevices: [AudioDevice] = []
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||
init(initialCategory: SettingsCategory? = nil) {
|
||||
_settingsSelection = State(initialValue: initialCategory)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||
@@ -66,6 +93,7 @@ struct SettingsView: View {
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
@@ -106,29 +134,115 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS: one grouped Form
|
||||
// MARK: - iOS / iPadOS: adaptive split view
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
Form {
|
||||
streamModeSection
|
||||
audioSection
|
||||
compositorSection
|
||||
presenterSection
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
Section {
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
// On iPhone the split view collapses to a push list, but a selection List
|
||||
// draws no disclosure indicator of its own — add one in compact width for the
|
||||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||
// the chevron is omitted there.
|
||||
HStack {
|
||||
Label(category.title, systemImage: category.symbol)
|
||||
if horizontalSizeClass == .compact {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// Purely a drill-in affordance — the row's button trait already
|
||||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.tag(category)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||
settingsDetail(settingsSelection ?? .general)
|
||||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||
.toolbar {
|
||||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||
if newValue == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .controllers:
|
||||
Form { controllersSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
@@ -156,6 +270,10 @@ struct SettingsView: View {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -186,20 +304,25 @@ struct SettingsView: View {
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: compositors, selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -219,7 +342,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -243,6 +366,63 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder private var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
@@ -253,6 +433,7 @@ struct SettingsView: View {
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
@@ -267,7 +448,7 @@ struct SettingsView: View {
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +458,85 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
|
||||
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
("720p", 1280, 720),
|
||||
("1080p", 1920, 1080),
|
||||
("1440p", 2560, 1440),
|
||||
("4K", 3840, 2160),
|
||||
("Ultrawide 1080p", 2560, 1080),
|
||||
("Ultrawide 1440p", 3440, 1440),
|
||||
("Super ultrawide", 5120, 1440),
|
||||
]
|
||||
|
||||
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
|
||||
/// dimensions (native wins a tie).
|
||||
private var resolutionModes: [(name: String, w: Int, h: Int)] {
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
|
||||
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
|
||||
var seen = Set<String>()
|
||||
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
/// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields.
|
||||
private var resolutionChoices: [(label: String, tag: String)] {
|
||||
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||
}
|
||||
|
||||
private var presetResolutionTags: Set<String> {
|
||||
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
|
||||
}
|
||||
|
||||
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
}
|
||||
|
||||
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||
/// sentinel toggles `customMode` instead of writing a size.
|
||||
private var resolutionSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
return
|
||||
}
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames the
|
||||
/// screen can't show), plus any stored custom value so it stays selectable.
|
||||
private var refreshChoices: [Int] {
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||
if rates.isEmpty { rates = [maxHz] }
|
||||
if !rates.contains(hz) { rates.append(hz) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -321,7 +576,7 @@ struct SettingsView: View {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +596,7 @@ struct SettingsView: View {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -355,26 +610,47 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
@ViewBuilder private var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var hdrSection: some View {
|
||||
Section {
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
} header: {
|
||||
Text("HDR")
|
||||
} footer: {
|
||||
Text("Request a 10-bit BT.2020 PQ (HDR10) stream. It only engages when the host is "
|
||||
+ "sending HDR content AND this display supports HDR — otherwise the stream stays "
|
||||
+ "8-bit SDR. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +668,7 @@ struct SettingsView: View {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -407,7 +683,7 @@ struct SettingsView: View {
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -441,7 +717,7 @@ struct SettingsView: View {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -593,13 +869,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.green.opacity(0.2)))
|
||||
@@ -621,6 +897,10 @@ struct SettingsView: View {
|
||||
width = Int(max(bounds.width, bounds.height))
|
||||
height = Int(min(bounds.width, bounds.height))
|
||||
hz = UIScreen.main.maximumFramesPerSecond
|
||||
#if os(iOS)
|
||||
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||
customMode = false
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -631,3 +911,52 @@ extension Double {
|
||||
Swift.min(Swift.max(self, lo), hi)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||
/// to the push list).
|
||||
@ViewBuilder
|
||||
func settingsSheetSizing() -> some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||
presentationSizing(.page)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
switch phase {
|
||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
||||
resultView(result)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
||||
if let rec = Self.recommendedKbps(result) {
|
||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||
+ "(~70% of measured, headroom for encoder bursts).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Too little data made it through to recommend a bitrate — "
|
||||
+ "check the network and retry.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
|
||||
// capturing it — the only accurate cursor for gamescope, whose capture has none.
|
||||
Text("⌘⇧C toggles the on-screen cursor")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases keyboard & mouse"
|
||||
: "⌘⎋ captures keyboard & mouse")
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
||||
// this button is the in-overlay, click-to-disconnect affordance.
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// or drops this and runs the PIN pairing ceremony instead.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct TrustCardView: View {
|
||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(hostName)")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
||||
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
||||
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
||||
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
||||
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
||||
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum BrandFont {
|
||||
public enum Weight {
|
||||
case regular, medium, semibold, bold
|
||||
}
|
||||
|
||||
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
||||
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
||||
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
||||
|
||||
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
||||
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
||||
private static let registered: Void = {
|
||||
for face in sansFaces {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
||||
#if DEBUG
|
||||
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
var error: Unmanaged<CFError>?
|
||||
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
||||
#if DEBUG
|
||||
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
||||
print("BrandFont: failed to register \(face): \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
||||
public static func registerIfNeeded() { _ = registered }
|
||||
|
||||
fileprivate static func sansFace(_ weight: Weight) -> String {
|
||||
switch weight {
|
||||
case .regular: return "Geist-Regular"
|
||||
case .medium: return "Geist-Medium"
|
||||
case .semibold: return "Geist-SemiBold"
|
||||
case .bold: return "Geist-Bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||
static let brand: Color = {
|
||||
#if canImport(UIKit)
|
||||
return Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#elseif canImport(AppKit)
|
||||
return Color(NSColor(name: nil) { appearance in
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#else
|
||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
static func geist(
|
||||
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
||||
relativeTo textStyle: TextStyle = .body
|
||||
) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
||||
}
|
||||
|
||||
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
||||
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
||||
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public enum DefaultsKey {
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
public static let micUID = "punktfunk.micUID"
|
||||
public static let presenter = "punktfunk.presenter"
|
||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||
public static let hdrEnabled = "punktfunk.hdrEnabled"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
|
||||
@@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
private var broken = false
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
|
||||
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that
|
||||
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
|
||||
// update immediately rebuilds into the same dead connection, flooding the log and never
|
||||
// recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
|
||||
// the moment a player runs cleanly (or the controller changes).
|
||||
private var retryAfter = Date.distantPast
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
@@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
@@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil {
|
||||
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
@@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to.
|
||||
if !ok { self.teardown() }
|
||||
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
|
||||
// update; once a player is actually running the path has recovered, so clear the backoff.
|
||||
if !ok {
|
||||
self.teardown()
|
||||
self.scheduleRetryBackoff()
|
||||
} else if self.low?.player != nil || self.high?.player != nil {
|
||||
self.consecutiveFailures = 0
|
||||
self.retryAfter = .distantPast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
low = makeMotor(haptics, .default)
|
||||
}
|
||||
if low == nil, high == nil {
|
||||
// Haptics present but no engine could be built right now (server busy / a transient
|
||||
// error). Do NOT latch broken — the next nonzero amplitude retries setup().
|
||||
log.warning("rumble: haptics present but engine setup failed — will retry on next rumble")
|
||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
||||
// NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
|
||||
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||
scheduleRetryBackoff()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
|
||||
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
|
||||
/// every rumble update.
|
||||
private func scheduleRetryBackoff() {
|
||||
consecutiveFailures += 1
|
||||
let shift = min(consecutiveFailures - 1, 4)
|
||||
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
|
||||
}
|
||||
|
||||
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
|
||||
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
|
||||
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
|
||||
engine.playsHapticsOnly = true
|
||||
// The haptic server can stop or reset the engine out from under us — app backgrounding, an
|
||||
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
|
||||
// unhandled the players go dead and every later rumble throws, latching rumble off for the
|
||||
|
||||
@@ -27,10 +27,35 @@ public enum Licenses {
|
||||
+ apache
|
||||
}
|
||||
|
||||
/// The bundled brand typeface (Geist Sans + Geist Mono) — SIL Open Font License 1.1. The
|
||||
/// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's
|
||||
/// distribution requirement; this surfaces it in the Acknowledgements screen too.
|
||||
public static var fontLicense: String {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// `scripts/gen-third-party-notices.sh`).
|
||||
public static var thirdPartyNotices: String {
|
||||
let text = resource("THIRD-PARTY-NOTICES")
|
||||
return text.isEmpty ? "Third-party notices unavailable." : text
|
||||
}
|
||||
|
||||
/// `thirdPartyNotices` pre-split into render-sized line chunks. The full notices are ~885 KB /
|
||||
/// 16k lines; a single SwiftUI `Text` that large overshoots CoreText/CoreAnimation's max
|
||||
/// renderable height — it lays out for ages and draws blank past the limit — so the
|
||||
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||
public static let thirdPartyNoticesChunks: [String] = {
|
||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let chunkSize = 200
|
||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import CoreGraphics
|
||||
import CoreVideo
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a
|
||||
/// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left-
|
||||
@@ -30,11 +33,44 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||
return o;
|
||||
}
|
||||
|
||||
// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger
|
||||
// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale
|
||||
// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1)
|
||||
// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact.
|
||||
// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear
|
||||
// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear.
|
||||
float catmullRomLuma(texture2d<float> tex, sampler s, float2 uv) {
|
||||
float2 texSize = float2(tex.get_width(), tex.get_height());
|
||||
float2 samplePos = uv * texSize;
|
||||
float2 tc1 = floor(samplePos - 0.5) + 0.5;
|
||||
float2 f = samplePos - tc1;
|
||||
float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
float2 w3 = f * f * (-0.5 + 0.5 * f);
|
||||
float2 w12 = w1 + w2;
|
||||
float2 off12 = w2 / w12;
|
||||
float2 tc0 = (tc1 - 1.0) / texSize;
|
||||
float2 tc3 = (tc1 + 2.0) / texSize;
|
||||
float2 tc12 = (tc1 + off12) / texSize;
|
||||
float r = 0.0;
|
||||
r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y);
|
||||
r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y);
|
||||
r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y);
|
||||
r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y);
|
||||
return r;
|
||||
}
|
||||
|
||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
||||
@@ -55,7 +91,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> chromaTex [[texture(1)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float y = lumaTex.sample(s, in.uv).r;
|
||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
||||
float2 c = chromaTex.sample(s, in.uv).rg;
|
||||
// BT.2020 10-bit limited (video) range → full-range PQ R'G'B'.
|
||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
||||
@@ -81,6 +117,11 @@ public final class MetalVideoPresenter {
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
/// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs.
|
||||
private var hdrActive = false
|
||||
#if DEBUG
|
||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only when a size changes
|
||||
/// (on first frame, a resize, or a host Reconfigure) instead of every frame.
|
||||
private var lastSizeSig = ""
|
||||
#endif
|
||||
|
||||
/// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1.
|
||||
public init?() {
|
||||
@@ -113,6 +154,12 @@ public final class MetalVideoPresenter {
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.framebufferOnly = true
|
||||
layer.isOpaque = true
|
||||
// Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let
|
||||
// the system compositor scale it to the layer's bounds — the same `.resizeAspect` path
|
||||
// stage-1's AVSampleBufferDisplayLayer (videoGravity) uses, so stage-2 matches its sharpness.
|
||||
// A native-resolution present is then pixel-exact (1:1, no shader scaling), and any display
|
||||
// scaling uses the system's high-quality scaler rather than the in-shader bicubic.
|
||||
layer.contentsGravity = .resizeAspect
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
||||
// display-link / MAIN thread) has to block waiting for one to free.
|
||||
layer.maximumDrawableCount = 3
|
||||
@@ -129,12 +176,6 @@ public final class MetalVideoPresenter {
|
||||
self.layer = layer
|
||||
}
|
||||
|
||||
/// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels.
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
if layer.drawableSize != size { layer.drawableSize = size }
|
||||
}
|
||||
|
||||
/// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an
|
||||
/// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the
|
||||
/// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`).
|
||||
@@ -171,13 +212,33 @@ public final class MetalVideoPresenter {
|
||||
let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache)
|
||||
else { return false }
|
||||
|
||||
// The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid
|
||||
// out. The fullscreen triangle scales the decoded texture to fill the drawable.
|
||||
guard layer.drawableSize.width > 0, layer.drawableSize.height > 0,
|
||||
let drawable = layer.nextDrawable(),
|
||||
// Size the drawable to the decoded frame so the fullscreen triangle samples the texture 1:1
|
||||
// (pixel-exact); the layer's contentsGravity then scales it to the on-screen bounds via the
|
||||
// system compositor (matching stage-1). Re-set only on a change (first frame / Reconfigure).
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize }
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
#if DEBUG
|
||||
// Diagnose sharpness: decoded should equal the drawable (the shader is 1:1); the layer's
|
||||
// bounds may differ (the system scales). Logged only when a size changes.
|
||||
let decodedW = Int(decodedSize.width)
|
||||
let decodedH = Int(decodedSize.height)
|
||||
let sig = "\(decodedW)x\(decodedH)|\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))"
|
||||
if sig != lastSizeSig {
|
||||
lastSizeSig = sig
|
||||
let msg = "stage2: decoded \(decodedW)x\(decodedH) → drawable "
|
||||
+ "\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height)) "
|
||||
+ "(texture \(drawable.texture.width)x\(drawable.texture.height), "
|
||||
+ "contentsScale \(layer.contentsScale), "
|
||||
+ "layerBounds \(Int(layer.bounds.width))x\(Int(layer.bounds.height)))"
|
||||
presenterLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
#endif
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = drawable.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
|
||||
@@ -182,6 +182,11 @@ public final class PunktfunkConnection {
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
// Valve Steam Controller / Steam Deck (Linux UHID hid-steam hosts). Parity only on Apple —
|
||||
// GameController never surfaces a 0x28DE HID device, so the client can't capture one; these
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
case steamDeck = 6
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -192,6 +197,8 @@ public final class PunktfunkConnection {
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -177,6 +177,16 @@ public final class SessionAudio {
|
||||
private var playbackEngine: AVAudioEngine?
|
||||
private var captureEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
/// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE
|
||||
/// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process
|
||||
/// singleton, and across a reconnect the old session's deactivate must be ordered before the
|
||||
/// new session's activate (a per-instance queue would let them race and leave the new session's
|
||||
/// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
@@ -189,37 +199,60 @@ public final class SessionAudio {
|
||||
flag.stop()
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system
|
||||
/// default device; on iOS the UIDs are ignored entirely (routes are
|
||||
/// AVAudioSession-managed). Main thread (engine setup); returns after the engines
|
||||
/// start — the mic may start slightly later if the permission prompt is pending.
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
#if os(iOS)
|
||||
// Route + policy live in the session, not per-engine: stereo playback, mic
|
||||
// capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults).
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
/// Route + policy live in the session, not per-engine: stereo playback, mic capture when
|
||||
/// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`.
|
||||
private func activateAudioSession(micEnabled: Bool) {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
#if os(iOS)
|
||||
if micEnabled {
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone
|
||||
// EARPIECE; only affects the built-in route (headphones/BT still win).
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||
// affects the built-in route (headphones/BT still win).
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
}
|
||||
#else // tvOS — no app-accessible mic
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
@@ -258,19 +291,24 @@ public final class SessionAudio {
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
||||
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
||||
// queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget.
|
||||
Self.sessionQueue.async {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if wasDraining {
|
||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||
}
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its
|
||||
// resume cue.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Playback (host → speaker)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
|
||||
//
|
||||
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick`
|
||||
// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||
// + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there).
|
||||
// Only the ring + decoder cross threads and both are internally locked.
|
||||
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
@@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable {
|
||||
func request() {
|
||||
lock.lock()
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request
|
||||
let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request (matches Android)
|
||||
if due { lastNs = now }
|
||||
let conn = due ? connection : nil
|
||||
lock.unlock()
|
||||
@@ -114,20 +114,24 @@ public final class Stage2Pipeline {
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
|
||||
// the old code advanced lastFramesDropped on the same edge it called recovery.request(),
|
||||
// so a request swallowed by the throttle (the lost recovery IDR being pruned within the
|
||||
// window) was never re-sent and the picture stayed frozen. Keep asking until an IDR lands.
|
||||
var awaitingIDR = false
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). The reassembler drops unrecoverable
|
||||
// AUs (framesDropped) and the decoder then conceals the reference-missing delta
|
||||
// frames that follow — often rendering them WITHOUT an error callback — so the
|
||||
// onDecodeError trigger rarely fires after a real network blip. Ask the host for
|
||||
// a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery).
|
||||
// Polled every iteration so a total-loss drought recovers the moment packets
|
||||
// resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs
|
||||
// (framesDropped) and the decoder conceals the reference-missing deltas that
|
||||
// follow — often WITHOUT an error callback — so key off the drop count climbing,
|
||||
// then keep asking (awaitingIDR) until a fresh IDR re-anchors decode. Polled every
|
||||
// iteration so a total-loss drought recovers the moment packets resume.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
lastFramesDropped = dropped
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which
|
||||
// attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these.
|
||||
if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||
@@ -136,15 +140,16 @@ public final class Stage2Pipeline {
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
guard let f = format, token.isLive else { continue }
|
||||
if !decoder.decode(au: au, format: f) {
|
||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
||||
// — and ask the host for that IDR now (infinite GOP; throttled).
|
||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy —
|
||||
// and keep asking for that IDR (infinite GOP) until one re-anchors decode.
|
||||
decoder.reset()
|
||||
recovery.request()
|
||||
awaitingIDR = true
|
||||
}
|
||||
} catch {
|
||||
if token.isLive { onSessionEnd?() }
|
||||
@@ -166,11 +171,6 @@ public final class Stage2Pipeline {
|
||||
presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
/// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure).
|
||||
public func setDrawableSize(_ size: CGSize) {
|
||||
presenter.setDrawableSize(size)
|
||||
}
|
||||
|
||||
/// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the
|
||||
/// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent).
|
||||
public func stop() {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
|
||||
|
||||
/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump
|
||||
/// its own token, so it can never be revived by a newer start().
|
||||
@@ -47,44 +50,74 @@ final class StreamPump {
|
||||
var format: CMVideoFormatDescription?
|
||||
var lastKeyframeRequest = Date.distantPast
|
||||
var lastFramesDropped = connection.framesDropped()
|
||||
// Coalesced host keyframe request: the decode stays wedged for several frames until
|
||||
// the IDR lands, so requesting on every frame would flood the control stream.
|
||||
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
|
||||
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
|
||||
// fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the
|
||||
// same edge it fired the throttled request — so a request swallowed by the throttle (a
|
||||
// second drop within the window, e.g. the lost recovery IDR itself being pruned) was
|
||||
// never re-sent: the counter went flat, the climb never re-fired, and the picture stayed
|
||||
// frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the
|
||||
// Mac's Ethernet never does.
|
||||
var awaitingIDR = false
|
||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||
var wasFailed = false
|
||||
// Coalesced host keyframe request. 100 ms throttle (matches the working Android path):
|
||||
// fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained
|
||||
// freeze can't flood the control stream.
|
||||
func requestKeyframeThrottled() {
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||
if now.timeIntervalSince(lastKeyframeRequest) > 0.1 {
|
||||
connection.requestKeyframe()
|
||||
lastKeyframeRequest = now
|
||||
}
|
||||
}
|
||||
while token.isLive {
|
||||
do {
|
||||
// Loss recovery (the primary recovery path). Under the host's infinite GOP the
|
||||
// only recovery keyframe is one we request. The reassembler drops unrecoverable
|
||||
// AUs (framesDropped); the decoder then *conceals* the reference-missing delta
|
||||
// frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to
|
||||
// .failed — so the .failed check below rarely fires after a real network blip.
|
||||
// Ask the host for a fresh IDR whenever the drop count climbs. Polled every
|
||||
// iteration (not just per AU) so a total-loss drought still recovers the moment
|
||||
// packets resume and the reassembler counts the gap.
|
||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||
// (framesDropped); the decoder then *conceals* the reference-missing deltas — a
|
||||
// frozen / garbage picture that never flips the layer to .failed — so key off the
|
||||
// drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled
|
||||
// every iteration so a total-loss drought still recovers when packets resume.
|
||||
let dropped = connection.framesDropped()
|
||||
if dropped > lastFramesDropped {
|
||||
// Log only on the false→true transition (once per recovery cycle), not per
|
||||
// dropped AU, so heavy loss doesn't spam the log.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.notice(
|
||||
"video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR")
|
||||
}
|
||||
lastFramesDropped = dropped
|
||||
requestKeyframeThrottled()
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { requestKeyframeThrottled() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||
onFrame?(au)
|
||||
if let f = AnnexB.formatDescription(fromIDR: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
let idrFormat = AnnexB.formatDescription(fromIDR: au.data)
|
||||
if let f = idrFormat {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
if awaitingIDR {
|
||||
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
|
||||
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if layer.status == .failed {
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
// IDR): flush and re-gate on the next in-band parameter sets (resuming with
|
||||
// a delta frame can't recover), AND ask the host for a fresh IDR. Throttled:
|
||||
// the layer stays .failed across several polls until the IDR lands.
|
||||
// IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above),
|
||||
// re-gate on the next in-band parameter sets and keep asking — enqueuing a
|
||||
// delta into a failed layer can't recover it.
|
||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||
layer.flush()
|
||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||
requestKeyframeThrottled()
|
||||
if idrFormat == nil {
|
||||
format = nil
|
||||
awaitingIDR = true
|
||||
}
|
||||
}
|
||||
wasFailed = failed
|
||||
guard let f = format,
|
||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||
token.isLive // don't enqueue a stale frame after a restart
|
||||
|
||||
@@ -245,6 +245,15 @@ public final class StreamLayerView: NSView {
|
||||
layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view
|
||||
}
|
||||
|
||||
public override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
// `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the
|
||||
// stage-2 metal sublayer's drawableSize could stay at the old size while the view grows —
|
||||
// the compositor then upscales a too-small drawable and the video turns blocky. Resize the
|
||||
// drawable here too so it always tracks the window's pixel size (no stale upscale).
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
// MARK: - Capture state machine
|
||||
|
||||
/// Clicking into the video engages capture; that click is local (engagement), so
|
||||
@@ -549,10 +558,17 @@ public final class StreamLayerView: NSView {
|
||||
cursorVisible = false
|
||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
||||
// pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -593,9 +609,11 @@ public final class StreamLayerView: NSView {
|
||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the
|
||||
/// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it.
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer
|
||||
/// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's
|
||||
/// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a
|
||||
/// resized window rescales through the system's filter (matching stage-1) instead of the shader.
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -604,14 +622,12 @@ public final class StreamLayerView: NSView {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = window?.backingScaleFactor ?? 1
|
||||
// No implicit resize animation; refresh contentsScale on a retina↔non-retina move.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = window?.backingScaleFactor ?? 1
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
|
||||
@@ -136,6 +136,13 @@ public final class StreamViewController: UIViewController {
|
||||
|
||||
public override func loadView() {
|
||||
view = StreamLayerUIView()
|
||||
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||
// doesn't capture self (no retain cycle with the registration).
|
||||
registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in
|
||||
vc.layoutMetalLayer()
|
||||
}
|
||||
#if os(iOS)
|
||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
||||
// cursor from our deltas, so the local one only diverges from it. This hides the
|
||||
@@ -219,10 +226,17 @@ public final class StreamViewController: UIViewController {
|
||||
inputCapture = capture
|
||||
#endif
|
||||
|
||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
||||
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||
// Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a
|
||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where
|
||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no
|
||||
// way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always
|
||||
// takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
||||
#else
|
||||
let forceStage1 = false
|
||||
#endif
|
||||
if !forceStage1,
|
||||
let meter = presentMeter,
|
||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -300,8 +314,8 @@ public final class StreamViewController: UIViewController {
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||
) {
|
||||
let metal = pipeline.layer
|
||||
metal.contentsScale = streamView.contentScaleFactor
|
||||
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||
// (contentsScale + frame + drawableSize are all set by layoutMetalLayer() just below.)
|
||||
streamView.layer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
stage2 = pipeline
|
||||
@@ -325,9 +339,20 @@ public final class StreamViewController: UIViewController {
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
/// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode,
|
||||
/// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's
|
||||
/// fullscreen triangle scales the decoded texture to fill it.
|
||||
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||
/// would size the drawable at point resolution → a pixelated, upscaled mess. Falls back to the
|
||||
/// main screen scale if the trait is still unspecified.
|
||||
private var renderScale: CGFloat {
|
||||
let s = traitCollection.displayScale
|
||||
return s > 0 ? s : UIScreen.main.scale
|
||||
}
|
||||
|
||||
/// Position the metal sublayer aspect-fit in the view (the host streams at the client's native
|
||||
/// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter
|
||||
/// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect)
|
||||
/// scales it to this frame via the system compositor (matching stage-1's videoGravity).
|
||||
private func layoutMetalLayer() {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
@@ -337,13 +362,11 @@ public final class StreamViewController: UIViewController {
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
let scale = streamView.contentScaleFactor
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true) // don't animate the resize
|
||||
metalLayer.contentsScale = scale
|
||||
metalLayer.contentsScale = renderScale
|
||||
metalLayer.frame = fit
|
||||
CATransaction.commit()
|
||||
stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale))
|
||||
}
|
||||
|
||||
private func teardownStage2() {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
import Metal
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class MetalPresenterTests: XCTestCase {
|
||||
/// `MetalVideoPresenter.init?()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUV→RGB
|
||||
/// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host
|
||||
/// means a shader failed to compile — this catches a malformed shader before it silently
|
||||
/// degrades stage-2 to a stage-1 fallback on device.
|
||||
func testPresenterInitCompilesShaders() throws {
|
||||
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||
throw XCTSkip("no Metal device available in this environment")
|
||||
}
|
||||
XCTAssertNotNil(
|
||||
MetalVideoPresenter(),
|
||||
"stage-2 Metal shaders failed to compile (presenter init returned nil)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -294,7 +294,13 @@ const RESOLUTIONS: [number, number, string][] = [
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
];
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense"];
|
||||
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
dualsense: "DualSense",
|
||||
steamdeck: "Steam Deck",
|
||||
};
|
||||
|
||||
const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
@@ -355,14 +361,17 @@ const SettingsSection: FC = () => {
|
||||
/>
|
||||
<Field label="Gamepad type" childrenContainerWidth="max">
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({
|
||||
data: g,
|
||||
label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense",
|
||||
}))}
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</Field>
|
||||
{s.gamepad === "steamdeck" && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
checked={s.mic_enabled}
|
||||
|
||||
@@ -113,12 +113,35 @@ async function ensureShortcut(): Promise<number> {
|
||||
return appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
||||
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
||||
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
||||
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
||||
* the documented source of truth. No-op when the optional API is absent.
|
||||
*/
|
||||
function disableSteamInputForShortcut(appId: number): void {
|
||||
try {
|
||||
const input = (
|
||||
SteamClient as unknown as {
|
||||
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
||||
}
|
||||
).Input;
|
||||
input?.SetSteamInputEnabledForApp?.(appId, false);
|
||||
} catch {
|
||||
/* a controller tweak must never break the launch */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
|
||||
*/
|
||||
export async function launchStream(host: string, port: number): Promise<void> {
|
||||
const appId = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
disableSteamInputForShortcut(appId);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`);
|
||||
|
||||
@@ -767,6 +767,7 @@ fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>,
|
||||
connector,
|
||||
frames.take().expect("Connected delivered once"),
|
||||
app.gamepad.escape_events(),
|
||||
app.gamepad.disconnect_events(),
|
||||
handle.stop.clone(),
|
||||
inhibit,
|
||||
&title,
|
||||
|
||||
+186
-32
@@ -18,7 +18,7 @@ use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
||||
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
||||
@@ -33,8 +33,15 @@ const G: f32 = 9.80665;
|
||||
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||
/// is leaving anyway); we only also raise the escape signal.
|
||||
///
|
||||
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
|
||||
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
|
||||
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
|
||||
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
@@ -58,6 +65,7 @@ impl PadInfo {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -89,6 +97,9 @@ pub struct GamepadService {
|
||||
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||
/// fullscreen + release capture.
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
|
||||
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
@@ -98,11 +109,12 @@ impl GamepadService {
|
||||
let pinned = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
|
||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -115,6 +127,7 @@ impl GamepadService {
|
||||
pinned,
|
||||
ctl,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +137,12 @@ impl GamepadService {
|
||||
self.escape_rx.clone()
|
||||
}
|
||||
|
||||
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
|
||||
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
|
||||
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
|
||||
self.disconnect_rx.clone()
|
||||
}
|
||||
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
@@ -188,6 +207,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -259,11 +285,22 @@ struct Worker {
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
|
||||
/// touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
|
||||
disconnect_tx: async_channel::Sender<()>,
|
||||
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||
chord_armed: bool,
|
||||
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
|
||||
/// when the chord is broken.
|
||||
chord_since: Option<Instant>,
|
||||
/// The disconnect signal already fired for the current hold — latched so it fires once.
|
||||
disconnect_fired: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -275,13 +312,22 @@ impl Worker {
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
// 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
|
||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
),
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,32 +343,90 @@ impl Worker {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||
self.reset_chord();
|
||||
}
|
||||
|
||||
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||
/// fires once per press). Called after each button-down updates `held_buttons`.
|
||||
/// fires once per press) and start the hold-to-disconnect timer. Called after each
|
||||
/// button-down updates `held_buttons`.
|
||||
fn maybe_fire_escape(&mut self) {
|
||||
if self.chord_armed {
|
||||
return;
|
||||
}
|
||||
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = true;
|
||||
self.chord_since = Some(Instant::now());
|
||||
let _ = self.escape_tx.try_send(());
|
||||
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
||||
tracing::info!(
|
||||
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||
fn maybe_fire_disconnect(&mut self) {
|
||||
if self.disconnect_fired {
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.chord_since {
|
||||
if since.elapsed() >= DISCONNECT_HOLD {
|
||||
self.disconnect_fired = true;
|
||||
let _ = self.disconnect_tx.try_send(());
|
||||
tracing::info!("gamepad escape chord held — disconnecting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-arm once the chord is broken (any of its buttons released).
|
||||
fn rearm_escape(&mut self) {
|
||||
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||
self.chord_armed = false;
|
||||
self.reset_chord();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the escape/disconnect chord latches. Called at every session boundary
|
||||
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
|
||||
/// path *always* ends the session while the chord is still physically held, so the matching
|
||||
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
|
||||
/// never runs — without this the latched state would leak into the next session and either
|
||||
/// swallow its first chord press or fire a stale disconnect on connect.
|
||||
fn reset_chord(&mut self) {
|
||||
self.chord_armed = false;
|
||||
self.chord_since = None;
|
||||
self.disconnect_fired = false;
|
||||
}
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
@@ -335,6 +439,56 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -344,11 +498,18 @@ fn run(
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
escape_tx: &async_channel::Sender<()>,
|
||||
disconnect_tx: &async_channel::Sender<()>,
|
||||
) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||
// own thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game
|
||||
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see
|
||||
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -361,9 +522,13 @@ fn run(
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
escape_tx: escape_tx.clone(),
|
||||
disconnect_tx: disconnect_tx.clone(),
|
||||
chord_armed: false,
|
||||
chord_since: None,
|
||||
disconnect_fired: false,
|
||||
};
|
||||
|
||||
let publish = |w: &Worker| {
|
||||
@@ -381,6 +546,7 @@ fn run(
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
w.attached = Some(c);
|
||||
w.last_axis = [i32::MIN; 6];
|
||||
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
w.set_sensors(true);
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
@@ -474,9 +640,11 @@ fn run(
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
@@ -484,41 +652,23 @@ fn run(
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: true,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: false,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample
|
||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||
@@ -559,6 +709,10 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||
// safe — a dropped stop heals within ~500 ms.
|
||||
|
||||
@@ -124,12 +124,13 @@ impl Capture {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
window: &adw::ApplicationWindow,
|
||||
connector: Arc<NativeClient>,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
inhibit_shortcuts: bool,
|
||||
title: &str,
|
||||
@@ -152,7 +153,7 @@ pub fn new(
|
||||
stats_label.set_margin_top(12);
|
||||
|
||||
let hint = gtk::Label::new(Some(
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
|
||||
));
|
||||
hint.add_css_class("osd");
|
||||
hint.set_halign(gtk::Align::Center);
|
||||
@@ -163,7 +164,9 @@ pub fn new(
|
||||
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||
// only way out on a Steam Deck).
|
||||
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
||||
let fs_hint = gtk::Label::new(Some(
|
||||
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)",
|
||||
));
|
||||
fs_hint.add_css_class("osd");
|
||||
fs_hint.set_halign(gtk::Align::Center);
|
||||
fs_hint.set_valign(gtk::Align::Start);
|
||||
@@ -297,6 +300,7 @@ pub fn new(
|
||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let cap = capture.clone();
|
||||
let window_k = window.clone();
|
||||
let stop_kb = stop.clone();
|
||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||
let chord = gdk::ModifierType::CONTROL_MASK
|
||||
| gdk::ModifierType::ALT_MASK
|
||||
@@ -309,6 +313,13 @@ pub fn new(
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||
cap.release();
|
||||
stop_kb.store(true, Ordering::SeqCst);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if keyval == gdk::Key::F11 {
|
||||
if window_k.is_fullscreen() {
|
||||
window_k.unfullscreen();
|
||||
@@ -442,6 +453,24 @@ pub fn new(
|
||||
})
|
||||
};
|
||||
|
||||
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
|
||||
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
|
||||
// this page (and fires `hidden` below). One-shot — the session is going away.
|
||||
let disconnect_future = {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
let stop_d = stop.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if disconnect_rx.recv().await.is_ok() {
|
||||
cap.release();
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_d.store(true, Ordering::SeqCst);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||
{
|
||||
@@ -449,6 +478,7 @@ pub fn new(
|
||||
let stop_h = stop.clone();
|
||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||
let escape_future = RefCell::new(Some(escape_future));
|
||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||
page.connect_hidden(move |_| {
|
||||
tracing::debug!("stream page hidden — ending session");
|
||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||
@@ -458,6 +488,9 @@ pub fn new(
|
||||
if let Some(f) = escape_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
|
||||
+108
-27
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -240,6 +247,9 @@ struct Worker {
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
}
|
||||
|
||||
@@ -252,13 +262,21 @@ impl Worker {
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
),
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,9 +292,33 @@ impl Worker {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +334,56 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -305,6 +397,10 @@ fn run(
|
||||
// thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -317,6 +413,7 @@ fn run(
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
};
|
||||
|
||||
@@ -426,9 +523,11 @@ fn run(
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
@@ -436,41 +535,23 @@ fn run(
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: true,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: false,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
||||
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||
|
||||
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
|
||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||
out.effect_len = n as u8;
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
// No new struct (PunktfunkHidOutput has no size guard): pack into the existing
|
||||
// `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
|
||||
out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
|
||||
out.pad = *pad;
|
||||
out.which = *side;
|
||||
out.effect[0..2].copy_from_slice(&litude.to_le_bytes());
|
||||
out.effect[2..4].copy_from_slice(&period.to_le_bytes());
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
|
||||
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
|
||||
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||
/// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
|
||||
|
||||
/// One rich client→host input for the host's virtual DualSense
|
||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||
@@ -666,6 +692,77 @@ impl PunktfunkRichInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
|
||||
/// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
|
||||
/// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
|
||||
/// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
|
||||
/// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
|
||||
/// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkRichInputEx {
|
||||
/// MUST equal `sizeof(PunktfunkRichInputEx)`.
|
||||
pub struct_size: u32,
|
||||
/// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
|
||||
pub kind: u8,
|
||||
/// Gamepad index.
|
||||
pub pad: u8,
|
||||
/// Touchpad/TouchpadEx: contact id.
|
||||
pub finger: u8,
|
||||
/// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
|
||||
pub active: u8,
|
||||
/// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
|
||||
pub surface: u8,
|
||||
/// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
|
||||
pub click: u8,
|
||||
/// Reserved for alignment; set to 0.
|
||||
pub _reserved: [u8; 2],
|
||||
/// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
|
||||
/// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
|
||||
pub x: i16,
|
||||
/// TouchpadEx: y coordinate — signed, centred at 0.
|
||||
pub y: i16,
|
||||
/// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
|
||||
pub pressure: u16,
|
||||
/// Motion: gyro (pitch, yaw, roll), raw signed-16.
|
||||
pub gyro: [i16; 3],
|
||||
/// Motion: accelerometer (x, y, z), raw signed-16.
|
||||
pub accel: [i16; 3],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkRichInputEx {
|
||||
fn to_rich(self) -> Option<crate::quic::RichInput> {
|
||||
use crate::quic::RichInput;
|
||||
match self.kind {
|
||||
PUNKTFUNK_RICH_TOUCHPAD_EX => Some(RichInput::TouchpadEx {
|
||||
pad: self.pad,
|
||||
surface: self.surface,
|
||||
finger: self.finger,
|
||||
touch: self.active != 0,
|
||||
click: self.click != 0,
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
pressure: self.pressure,
|
||||
}),
|
||||
PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
|
||||
pad: self.pad,
|
||||
gyro: self.gyro,
|
||||
accel: self.accel,
|
||||
}),
|
||||
PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
|
||||
pad: self.pad,
|
||||
finger: self.finger,
|
||||
active: self.active != 0,
|
||||
x: self.x as u16,
|
||||
y: self.y as u16,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||
#[cfg(feature = "quic")]
|
||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
||||
@@ -714,6 +811,22 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||
/// 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
|
||||
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||
|
||||
/// 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
|
||||
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
@@ -742,11 +855,28 @@ const _: () = {
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
const _: () = {
|
||||
use crate::config::GamepadPref;
|
||||
use crate::input::gamepad as g;
|
||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.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_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
|
||||
};
|
||||
|
||||
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
|
||||
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
|
||||
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
@@ -1727,6 +1857,43 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
|
||||
/// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
|
||||
/// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
|
||||
/// `struct_size` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||
c: *mut PunktfunkConnection,
|
||||
rich: *const PunktfunkRichInputEx,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if rich.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
// Read only the 4-byte size prefix first to bound the subsequent full read (the
|
||||
// `PunktfunkConfig` ABI-skew precedent).
|
||||
let declared = unsafe { std::ptr::addr_of!((*rich).struct_size).read_unaligned() } as usize;
|
||||
if declared < std::mem::size_of::<PunktfunkRichInputEx>() {
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
match unsafe { *rich }.to_rich() {
|
||||
Some(r) => match c.inner.send_rich_input(r) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
},
|
||||
None => PunktfunkStatus::InvalidArg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The currently active session mode — the Welcome's, until an accepted
|
||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||
///
|
||||
|
||||
@@ -137,8 +137,9 @@ impl CompositorPref {
|
||||
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
|
||||
/// 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
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply
|
||||
/// omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -155,10 +156,19 @@ pub enum GamepadPref {
|
||||
/// 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.
|
||||
DualShock4,
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro,
|
||||
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
|
||||
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
|
||||
SteamController,
|
||||
/// 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
|
||||
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
||||
SteamDeck,
|
||||
}
|
||||
|
||||
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`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -166,6 +176,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualSense => 2,
|
||||
GamepadPref::XboxOne => 3,
|
||||
GamepadPref::DualShock4 => 4,
|
||||
GamepadPref::SteamController => 5,
|
||||
GamepadPref::SteamDeck => 6,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +189,8 @@ impl GamepadPref {
|
||||
2 => GamepadPref::DualSense,
|
||||
3 => GamepadPref::XboxOne,
|
||||
4 => GamepadPref::DualShock4,
|
||||
5 => GamepadPref::SteamController,
|
||||
6 => GamepadPref::SteamDeck,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -192,12 +206,14 @@ impl GamepadPref {
|
||||
GamepadPref::XboxOne
|
||||
}
|
||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
||||
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`).
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -205,6 +221,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualSense => "dualsense",
|
||||
GamepadPref::XboxOne => "xboxone",
|
||||
GamepadPref::DualShock4 => "dualshock4",
|
||||
GamepadPref::SteamController => "steamcontroller",
|
||||
GamepadPref::SteamDeck => "steamdeck",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,4 +399,27 @@ mod tests {
|
||||
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_pref_steam_roundtrip() {
|
||||
use GamepadPref::*;
|
||||
// Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto.
|
||||
for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] {
|
||||
assert_eq!(p.to_u8(), b);
|
||||
assert_eq!(GamepadPref::from_u8(b), p);
|
||||
}
|
||||
assert_eq!(GamepadPref::from_u8(99), Auto);
|
||||
// Name parsing + canonical-name round-trip.
|
||||
assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck));
|
||||
assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("steamcontroller"),
|
||||
Some(SteamController)
|
||||
);
|
||||
assert_eq!(SteamDeck.as_str(), "steamdeck");
|
||||
assert_eq!(
|
||||
GamepadPref::from_name(SteamController.as_str()),
|
||||
Some(SteamController)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,24 @@ pub mod gamepad {
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in Moonlight's `buttonFlags2 << 16` namespace (see `gamestream/gamepad.rs`),
|
||||
// so the GameStream paddle path and the native path share one host injector map. The four Steam
|
||||
// Deck back grips (L4/L5/R4/R5) reuse the four GameStream/Xbox-Elite paddle slots — a semantic
|
||||
// 1:1 for binding (the device identity carries the glyph distinction).
|
||||
/// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
/// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
/// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
/// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
||||
/// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
pub const BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Axis ids for `InputKind::GamepadAxis`.
|
||||
pub const AXIS_LS_X: u32 = 0;
|
||||
|
||||
@@ -1218,6 +1218,7 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
|
||||
const RICH_TOUCHPAD: u8 = 0x01;
|
||||
const RICH_MOTION: u8 = 0x02;
|
||||
const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
|
||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -1241,6 +1242,22 @@ pub enum RichInput {
|
||||
gyro: [i16; 3],
|
||||
accel: [i16; 3],
|
||||
},
|
||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
|
||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
||||
TouchpadEx {
|
||||
pad: u8,
|
||||
surface: u8,
|
||||
finger: u8,
|
||||
touch: bool,
|
||||
click: bool,
|
||||
x: i16,
|
||||
y: i16,
|
||||
pressure: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
@@ -1264,6 +1281,22 @@ impl RichInput {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
pad,
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
pressure,
|
||||
} => {
|
||||
let state = (touch as u8) | ((click as u8) << 1);
|
||||
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
out.extend_from_slice(&pressure.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1288,6 +1321,16 @@ impl RichInput {
|
||||
accel: [i16at(9), i16at(11), i16at(13)],
|
||||
})
|
||||
}
|
||||
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
|
||||
pad: b[2],
|
||||
surface: b[3],
|
||||
finger: b[4],
|
||||
touch: b[5] & 0x01 != 0,
|
||||
click: b[5] & 0x02 != 0,
|
||||
x: i16::from_le_bytes([b[6], b[7]]),
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1296,6 +1339,7 @@ impl RichInput {
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
|
||||
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -1309,6 +1353,16 @@ pub enum HidOutput {
|
||||
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
|
||||
/// trigger parameter block (mode + params) for the client to replay on a real controller.
|
||||
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
amplitude: u16,
|
||||
period: u16,
|
||||
count: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -1325,6 +1379,18 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
|
||||
out.extend_from_slice(&litude.to_le_bytes());
|
||||
out.extend_from_slice(&period.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1349,6 +1415,13 @@ impl HidOutput {
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
side: b[3],
|
||||
amplitude: u16::from_le_bytes([b[4], b[5]]),
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
count: u16::from_le_bytes([b[8], b[9]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2486,6 +2559,16 @@ mod tests {
|
||||
gyro: [-100, 200, -300],
|
||||
accel: [16384, -8192, 1],
|
||||
},
|
||||
RichInput::TouchpadEx {
|
||||
pad: 2,
|
||||
surface: 1,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: -12345,
|
||||
y: 30000,
|
||||
pressure: 4000,
|
||||
},
|
||||
] {
|
||||
let d = ev.encode();
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
@@ -2494,7 +2577,8 @@ mod tests {
|
||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
|
||||
// short
|
||||
}
|
||||
|
||||
@@ -2516,6 +2600,13 @@ mod tests {
|
||||
which: 1,
|
||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||
},
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 1,
|
||||
amplitude: 0x1234,
|
||||
period: 0x5678,
|
||||
count: 9,
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
|
||||
@@ -89,6 +89,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||
# `wayland-backend` is referenced by the generated interface tables.
|
||||
@@ -119,6 +122,10 @@ ash = "0.38"
|
||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
||||
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||
libloading = "0.8"
|
||||
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
|
||||
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
|
||||
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
|
||||
usbip-sim = { path = "vendor/usbip-sim" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver runs in a restricted WUDFHost
|
||||
//! token that canNOT create named kernel objects, so — exactly like the gamepad UMDF drivers
|
||||
//! (`inject/dualsense_windows.rs`) — the HOST (privileged) CREATES the shared header + frame-ready
|
||||
//! event + ring of keyed-mutex textures (`Global\` names, permissive `D:(A;;GA;;;WD)` SDDL) on the
|
||||
//! discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
||||
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named
|
||||
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the
|
||||
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures
|
||||
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host —
|
||||
//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring
|
||||
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by
|
||||
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
@@ -236,13 +236,17 @@ pub struct IddPushCapturer {
|
||||
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
|
||||
unsafe impl Send for IddPushCapturer {}
|
||||
|
||||
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver
|
||||
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses.
|
||||
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
|
||||
unsafe fn permissive_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the
|
||||
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under,
|
||||
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The
|
||||
/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and
|
||||
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29):
|
||||
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS
|
||||
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`.
|
||||
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
@@ -269,7 +273,7 @@ impl IddPushCapturer {
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<Vec<HostSlot>> {
|
||||
let (sa, _psd) = permissive_sa()?;
|
||||
let (sa, _psd) = shared_object_sa()?;
|
||||
let mut slots = Vec::new();
|
||||
for k in 0..RING_LEN {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
@@ -375,7 +379,7 @@ impl IddPushCapturer {
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `permissive_sa`, `CreateFileMappingW`,
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
|
||||
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
||||
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
||||
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
|
||||
@@ -421,7 +425,7 @@ impl IddPushCapturer {
|
||||
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||
|
||||
let (sa, _psd) = permissive_sa()?;
|
||||
let (sa, _psd) = shared_object_sa()?;
|
||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||
|
||||
// Header.
|
||||
|
||||
@@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000;
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
|
||||
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
|
||||
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
|
||||
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
|
||||
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
|
||||
|
||||
@@ -101,6 +101,10 @@ struct Session {
|
||||
server_challenge: [u8; 16],
|
||||
/// The client's phase-3 hash, recomputed + checked in phase 4.
|
||||
client_hash: Vec<u8>,
|
||||
/// Set once phase 3 has produced the RSA-signed serversecret. A repeated phase 3 is refused so a
|
||||
/// peer past phase 1 can't loop phase2/phase3 to harvest many signing-time samples (a passive
|
||||
/// timing-oracle amplifier vs. the rsa-crate Marvin side-channel; see `.cargo/audit.toml`).
|
||||
responded: bool,
|
||||
}
|
||||
|
||||
pub struct Pairing {
|
||||
@@ -155,6 +159,7 @@ impl Pairing {
|
||||
serversecret: [0; 16],
|
||||
server_challenge: [0; 16],
|
||||
client_hash: Vec::new(),
|
||||
responded: false,
|
||||
},
|
||||
);
|
||||
tracing::info!(
|
||||
@@ -216,6 +221,14 @@ impl Pairing {
|
||||
bail!("short challenge response");
|
||||
}
|
||||
s.client_hash = client_hash[..32].to_vec();
|
||||
// Sign the serversecret exactly ONCE per ceremony: refuse a repeated phase 3 so a peer that
|
||||
// cleared phase 1 (operator PIN) can't replay it to collect many RSA signing-time samples
|
||||
// (timing-oracle amplifier vs. RUSTSEC-2023-0071; see `.cargo/audit.toml`). A legit client
|
||||
// signs once. The session stays for phase 4 (the cert-pin step) but won't re-sign.
|
||||
if s.responded {
|
||||
bail!("serverchallengeresp already answered for this pairing session");
|
||||
}
|
||||
s.responded = true;
|
||||
let sig: Signature = id.signing_key.sign(&s.serversecret);
|
||||
let mut secret = Vec::with_capacity(16 + 256);
|
||||
secret.extend_from_slice(&s.serversecret);
|
||||
|
||||
@@ -491,6 +491,31 @@ pub mod gamepad;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/gamepad_raii.rs"]
|
||||
mod gamepad_raii;
|
||||
/// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_controller.rs"]
|
||||
pub mod steam_controller;
|
||||
/// 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).
|
||||
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_gadget.rs"]
|
||||
pub mod steam_gadget;
|
||||
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
||||
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_proto.rs"]
|
||||
pub mod steam_proto;
|
||||
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_remap.rs"]
|
||||
pub mod steam_remap;
|
||||
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
|
||||
/// Steam-Input-promotable virtual-Deck transport on non-SteamOS hosts (Bazzite/generic), where
|
||||
/// `dummy_hcd`/`raw_gadget` aren't built. In-tree + signed; no module build, no MOK.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_usbip.rs"]
|
||||
pub mod steam_usbip;
|
||||
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod gamepad {
|
||||
|
||||
@@ -182,6 +182,9 @@ pub struct DualSenseManager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualSenseManager {
|
||||
@@ -198,6 +201,7 @@ impl DualSenseManager {
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +233,12 @@ impl DualSenseManager {
|
||||
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
|
||||
// come on the rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
@@ -252,7 +260,9 @@ impl DualSenseManager {
|
||||
/// arrived first); they're dropped if the pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -280,6 +290,26 @@ impl DualSenseManager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ pub struct DualShock4Manager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualShock4Manager {
|
||||
@@ -384,6 +387,7 @@ impl DualShock4Manager {
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +420,12 @@ impl DualShock4Manager {
|
||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
|
||||
// rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
@@ -439,7 +447,9 @@ impl DualShock4Manager {
|
||||
/// pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -466,6 +476,26 @@ impl DualShock4Manager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -69,9 +69,16 @@ const BTN_START: u16 = 0x13b;
|
||||
const BTN_MODE: u16 = 0x13c;
|
||||
const BTN_THUMBL: u16 = 0x13d;
|
||||
const BTN_THUMBR: u16 = 0x13e;
|
||||
// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips —
|
||||
// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so
|
||||
// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
const BTN_TRIGGER_HAPPY5: u16 = 0x2c4;
|
||||
const BTN_TRIGGER_HAPPY6: u16 = 0x2c5;
|
||||
const BTN_TRIGGER_HAPPY7: u16 = 0x2c6;
|
||||
const BTN_TRIGGER_HAPPY8: u16 = 0x2c7;
|
||||
|
||||
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
||||
const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
const BUTTON_MAP: [(u32, u16); 15] = [
|
||||
(gamepad::BTN_A, BTN_SOUTH),
|
||||
(gamepad::BTN_B, BTN_EAST),
|
||||
(gamepad::BTN_X, BTN_NORTH),
|
||||
@@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
||||
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
|
||||
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
|
||||
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
|
||||
(gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8),
|
||||
];
|
||||
|
||||
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
|
||||
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
|
||||
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
|
||||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which
|
||||
//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed
|
||||
//! output's pixels.
|
||||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
|
||||
//!
|
||||
//! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed
|
||||
//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at
|
||||
//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far
|
||||
//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry
|
||||
//! (position + size) via `xdg-output` and map the normalized client position into the matching
|
||||
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
@@ -18,8 +23,14 @@
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::{
|
||||
zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||||
zxdg_output_v1::{self, ZxdgOutputV1},
|
||||
};
|
||||
|
||||
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
|
||||
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
|
||||
@@ -48,10 +59,39 @@ const AXIS_HORIZONTAL: u32 = 1;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry
|
||||
/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0`
|
||||
/// means xdg-output hasn't reported its size yet.
|
||||
struct OutputTrack {
|
||||
/// Registry global id — also the dispatch user-data, so events route back to this entry.
|
||||
name: u32,
|
||||
wl_output: WlOutput,
|
||||
xdg_output: Option<ZxdgOutputV1>,
|
||||
/// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH.
|
||||
mode_w: i32,
|
||||
mode_h: i32,
|
||||
/// Logical (post-scale) geometry from `xdg-output`.
|
||||
logical_x: i32,
|
||||
logical_y: i32,
|
||||
logical_w: i32,
|
||||
logical_h: i32,
|
||||
}
|
||||
|
||||
/// Registry-bound globals (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
fake: Option<FakeInput>,
|
||||
xdg_mgr: Option<ZxdgOutputManagerV1>,
|
||||
outputs: Vec<OutputTrack>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Create the `xdg_output` for a tracked output once both it and the manager exist.
|
||||
fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle<State>) {
|
||||
if o.xdg_output.is_none() {
|
||||
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
@@ -63,15 +103,57 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
if interface == "org_kde_kwin_fake_input" {
|
||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"org_kde_kwin_fake_input" => {
|
||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||||
}
|
||||
"wl_output" => {
|
||||
// v1 carries `mode` (all we need); bind no higher than the proxy's max (4).
|
||||
let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name);
|
||||
let mut o = OutputTrack {
|
||||
name,
|
||||
wl_output,
|
||||
xdg_output: None,
|
||||
mode_w: 0,
|
||||
mode_h: 0,
|
||||
logical_x: 0,
|
||||
logical_y: 0,
|
||||
logical_w: 0,
|
||||
logical_h: 0,
|
||||
};
|
||||
if let Some(mgr) = state.xdg_mgr.clone() {
|
||||
State::ensure_xdg_output(&mut o, &mgr, qh);
|
||||
}
|
||||
state.outputs.push(o);
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ());
|
||||
// Outputs bound before the manager have no xdg_output yet — create them now.
|
||||
for o in state.outputs.iter_mut() {
|
||||
State::ensure_xdg_output(o, &mgr, qh);
|
||||
}
|
||||
state.xdg_mgr = Some(mgr);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| {
|
||||
if o.name == name {
|
||||
if let Some(x) = &o.xdg_output {
|
||||
x.destroy();
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,13 +171,86 @@ impl Dispatch<FakeInput, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the *current* mode matters — a real monitor also advertises its other supported modes.
|
||||
if let wl_output::Event::Mode {
|
||||
flags: WEnum::Value(flags),
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if flags.contains(wl_output::Mode::Current) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
o.mode_w = width;
|
||||
o.mode_h = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgOutputV1, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZxdgOutputV1,
|
||||
event: zxdg_output_v1::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
match event {
|
||||
zxdg_output_v1::Event::LogicalPosition { x, y } => {
|
||||
o.logical_x = x;
|
||||
o.logical_y = y;
|
||||
}
|
||||
zxdg_output_v1::Event::LogicalSize { width, height } => {
|
||||
o.logical_w = width;
|
||||
o.logical_h = height;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events.
|
||||
impl Dispatch<ZxdgOutputManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ZxdgOutputManagerV1,
|
||||
_: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KwinFakeInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
fake: FakeInput,
|
||||
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
|
||||
last_refresh: Option<Instant>,
|
||||
}
|
||||
|
||||
/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove
|
||||
/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy
|
||||
/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a
|
||||
/// second — versus a blocking roundtrip on every single mouse-move event.
|
||||
const GEO_REFRESH: Duration = Duration::from_millis(500);
|
||||
|
||||
impl KwinFakeInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let conn = Connection::connect_to_env()
|
||||
@@ -122,13 +277,77 @@ impl KwinFakeInjector {
|
||||
.context("fake_input authenticate roundtrip")?;
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)");
|
||||
Ok(Self {
|
||||
// Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
|
||||
// above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to
|
||||
// scale-1 mapping if xdg-output is absent.
|
||||
let mut injector = Self {
|
||||
conn,
|
||||
queue,
|
||||
state,
|
||||
fake,
|
||||
})
|
||||
last_refresh: None,
|
||||
};
|
||||
injector.refresh_geometry();
|
||||
tracing::info!(
|
||||
outputs = injector.state.outputs.len(),
|
||||
"KWin fake_input ready (headless keyboard/mouse/touch — no portal)"
|
||||
);
|
||||
Ok(injector)
|
||||
}
|
||||
|
||||
/// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending
|
||||
/// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this
|
||||
/// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later
|
||||
/// roundtrip — keep going (bounded) until every output is settled.
|
||||
fn refresh_geometry(&mut self) {
|
||||
let now = Instant::now();
|
||||
if let Some(t) = self.last_refresh {
|
||||
if now.duration_since(t) < GEO_REFRESH {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.last_refresh = Some(now);
|
||||
for _ in 0..3 {
|
||||
if self.queue.roundtrip(&mut self.state).is_err() {
|
||||
return;
|
||||
}
|
||||
let pending =
|
||||
self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0);
|
||||
if !pending {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the logical (global-compositor-space) rectangle to map a normalized client position
|
||||
/// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the
|
||||
/// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable
|
||||
/// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1).
|
||||
fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) {
|
||||
let usable = || {
|
||||
self.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|o| o.logical_w > 0 && o.logical_h > 0)
|
||||
};
|
||||
let chosen = usable()
|
||||
.find(|o| o.mode_w == phys_w && o.mode_h == phys_h)
|
||||
.or_else(|| {
|
||||
let mut it = usable();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(only), None) => Some(only),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
match chosen {
|
||||
Some(o) => (
|
||||
o.logical_x as f64,
|
||||
o.logical_y as f64,
|
||||
o.logical_w as f64,
|
||||
o.logical_h as f64,
|
||||
),
|
||||
None => (0.0, 0.0, phys_w as f64, phys_h as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +358,17 @@ impl InputInjector for KwinFakeInjector {
|
||||
self.fake.pointer_motion(event.x as f64, event.y as f64);
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as f64;
|
||||
let y = event.y.clamp(0, h as i32) as f64;
|
||||
self.fake.pointer_motion_absolute(x, y);
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
// Normalize in the streamed (physical) pixel space, then place inside the output's
|
||||
// logical rectangle — so display scaling no longer offsets the cursor.
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
self.fake
|
||||
.pointer_motion_absolute(lx + nx * lw, ly + ny * lh);
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
@@ -179,11 +403,15 @@ impl InputInjector for KwinFakeInjector {
|
||||
// Touch: id = event.code, coords in the client surface w×h packed into flags (same
|
||||
// absolute mapping as MouseMoveAbs). Each event is its own frame.
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as f64;
|
||||
let y = event.y.clamp(0, h as i32) as f64;
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
let x = lx + nx * lw;
|
||||
let y = ly + ny * lh;
|
||||
if event.kind == InputKind::TouchDown {
|
||||
self.fake.touch_down(event.code, x, y);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Virtual Steam Deck controller via UHID — the Steam analogue of the virtual DualSense
|
||||
//! ([`super::dualsense`]). A UHID device with Valve VID `28DE` / Deck PID `1205` is bound by the
|
||||
//! kernel `hid-steam` driver, which exposes a full Steam Deck gamepad evdev (incl. the four back
|
||||
//! grips) **plus** a separate IMU evdev, and — when Steam runs on the host — is re-grabbed by Steam
|
||||
//! Input with native glyphs + trackpad/gyro/back-button bindings.
|
||||
//!
|
||||
//! The transport-independent contract (descriptor, byte-exact serializer, the `XInput`/rich
|
||||
//! mappers, the rumble parser) lives in [`super::steam_proto`]; this module is the `/dev/uhid`
|
||||
//! plumbing + the two Steam-specific lifecycle quirks the DualSense path lacks:
|
||||
//!
|
||||
//! 1. **`gamepad_mode` entry.** `steam_do_deck_input_event` early-returns under the default
|
||||
//! `lizard_mode` until `gamepad_mode` is toggled on — which the kernel only does when the `b9.6`
|
||||
//! Steam/menu-right button is held ~450 ms with no hidraw client open. So on the first pad we
|
||||
//! best-effort clear `lizard_mode` via sysfs (needs root; bypasses the gate entirely) AND every
|
||||
//! pad pulses `b9.6` for [`MODE_ENTER`] at creation. After that an **anti-toggle guard** caps any
|
||||
//! continuous `b9.6` (a long in-game Start-hold) below the kernel's 450 ms threshold so play can
|
||||
//! never accidentally flip `gamepad_mode` back off.
|
||||
//! 2. **`UHID_SET_REPORT`.** Steam feedback (`0xEB` rumble) + the kernel's settings/serial writes
|
||||
//! arrive as FEATURE set-reports that MUST be answered `err = 0`, or the kernel stalls ~5 s per
|
||||
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
|
||||
|
||||
use super::steam_proto::{
|
||||
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
|
||||
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
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;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
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 UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// Cap continuous `b9.6` (Start) below the kernel's 450 ms mode-switch threshold: after this long
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
fn try_clear_lizard_mode() {
|
||||
static TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if TRIED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match std::fs::write("/sys/module/hid_steam/parameters/lizard_mode", "N") {
|
||||
Ok(()) => {
|
||||
tracing::info!("cleared hid_steam lizard_mode (Steam Deck gamepad events always flow)")
|
||||
}
|
||||
Err(e) => tracing::debug!(
|
||||
error = %e,
|
||||
"could not clear hid_steam lizard_mode (no root?) — using the gamepad_mode pulse + guard"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
|
||||
/// down the bound `hid-steam` interface + both evdevs).
|
||||
pub struct SteamDeckPad {
|
||||
fd: File,
|
||||
seq: u32,
|
||||
created: Instant,
|
||||
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
|
||||
menu_hold_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SteamDeckPad {
|
||||
pub fn open(index: u8) -> Result<SteamDeckPad> {
|
||||
try_clear_lizard_mode();
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = SteamDeckPad {
|
||||
fd,
|
||||
seq: 0,
|
||||
created: Instant::now(),
|
||||
menu_hold_since: None,
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?;
|
||||
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());
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64]
|
||||
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[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it.
|
||||
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
|
||||
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];
|
||||
serialize_deck_state(&mut r, &s, self.seq);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
|
||||
fn in_mode_entry(&self) -> bool {
|
||||
self.created.elapsed() < MODE_ENTER
|
||||
}
|
||||
|
||||
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
|
||||
/// drop `b9.6` for one frame whenever it's been continuously held past [`MENU_HOLD_CAP`].
|
||||
fn effective_buttons(&mut self, mut buttons: u64) -> u64 {
|
||||
if self.in_mode_entry() {
|
||||
return btn::STEAM_MENU_RIGHT;
|
||||
}
|
||||
if buttons & btn::MENU != 0 {
|
||||
let now = Instant::now();
|
||||
match self.menu_hold_since {
|
||||
None => self.menu_hold_since = Some(now),
|
||||
Some(since) if now.duration_since(since) >= MENU_HOLD_CAP => {
|
||||
buttons &= !btn::MENU; // one-frame release resets the kernel's mode-switch timer
|
||||
self.menu_hold_since = None;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
} else {
|
||||
self.menu_hold_since = None;
|
||||
}
|
||||
buttons
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's GET_REPORT (serial) + SET_REPORT
|
||||
/// (settings / rumble — ack `err=0`) and parse any rumble feedback (`0xEB`, on either the
|
||||
/// SET_REPORT or OUTPUT path) into `(low, high)` for the universal rumble plane.
|
||||
pub fn service(&mut self) -> Option<(u16, u16)> {
|
||||
let mut rumble = None;
|
||||
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 => {
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[4..end]).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
rumble
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SteamDeckPad {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Deck pads of a session — the Steam analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=steamdeck`.
|
||||
/// Button/stick frames arrive via [`handle`](Self::handle); the right trackpad + motion via
|
||||
/// [`apply_rich`](Self::apply_rich); [`pump`](Self::pump) services the kernel handshake + routes
|
||||
/// rumble back; [`heartbeat`](Self::heartbeat) keeps the pad alive (and drives the mode-entry pulse).
|
||||
/// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID
|
||||
/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS)
|
||||
/// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam
|
||||
/// Input *does* promote. Selected per-pad by [`open_transport`].
|
||||
enum DeckTransport {
|
||||
Uhid(SteamDeckPad),
|
||||
Gadget(crate::inject::steam_gadget::SteamDeckGadget),
|
||||
Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
|
||||
}
|
||||
|
||||
impl DeckTransport {
|
||||
fn write_state(&mut self, st: &SteamState) {
|
||||
match self {
|
||||
DeckTransport::Uhid(p) => {
|
||||
let _ = p.write_state(st);
|
||||
}
|
||||
DeckTransport::Gadget(g) => g.write_state(st),
|
||||
DeckTransport::Usbip(u) => u.write_state(st),
|
||||
}
|
||||
}
|
||||
fn service(&mut self) -> Option<(u16, u16)> {
|
||||
match self {
|
||||
DeckTransport::Uhid(p) => p.service(),
|
||||
DeckTransport::Gadget(g) => g.service().rumble,
|
||||
DeckTransport::Usbip(u) => u.service().rumble,
|
||||
}
|
||||
}
|
||||
fn in_mode_entry(&self) -> bool {
|
||||
match self {
|
||||
// Only the UHID pad needs the gamepad-mode entry pulse: the promoted transports are
|
||||
// read raw via hidraw by Steam Input, which bypasses the kernel's evdev mode gate.
|
||||
DeckTransport::Uhid(p) => p.in_mode_entry(),
|
||||
DeckTransport::Gadget(_) | DeckTransport::Usbip(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
|
||||
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
|
||||
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
|
||||
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
|
||||
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
|
||||
fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
use crate::inject::{steam_gadget, steam_usbip};
|
||||
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
|
||||
if steam_gadget::gadget_preferred() {
|
||||
steam_gadget::ensure_modules();
|
||||
match steam_gadget::SteamDeckGadget::open(idx) {
|
||||
Ok(g) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (USB gadget — Steam Input recognizes it)"
|
||||
);
|
||||
return Ok(DeckTransport::Gadget(g));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "USB-gadget Deck unavailable — trying usbip")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. usbip/vhci_hcd — the universal, in-tree, Secure-Boot-clean transport (default on elsewhere).
|
||||
if steam_usbip::usbip_preferred() {
|
||||
match steam_usbip::SteamDeckUsbip::open(idx) {
|
||||
Ok(u) => return Ok(DeckTransport::Usbip(u)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "usbip Deck unavailable — falling back to UHID")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it).
|
||||
let p = SteamDeckPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)"
|
||||
);
|
||||
Ok(DeckTransport::Uhid(p))
|
||||
}
|
||||
|
||||
pub struct SteamControllerManager {
|
||||
pads: Vec<Option<DeckTransport>>,
|
||||
state: Vec<SteamState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_write: Vec<Instant>,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Default for SteamControllerManager {
|
||||
fn default() -> SteamControllerManager {
|
||||
SteamControllerManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SteamControllerManager {
|
||||
pub fn new() -> SteamControllerManager {
|
||||
SteamControllerManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![SteamState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (Steam Deck)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged (Steam Deck)");
|
||||
*slot = None;
|
||||
self.state[i] = SteamState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion
|
||||
// arrive separately and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
let mut s = SteamState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.rpad_x = prev.rpad_x;
|
||||
s.rpad_y = prev.rpad_y;
|
||||
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::RPAD_TOUCH | btn::LPAD_TOUCH);
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a rich client→host event (right trackpad / motion) to an existing pad.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
self.state[idx].apply_rich(rich);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
pad.write_state(&st);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's current report when silent past `max_gap`, and force a steady stream
|
||||
/// while a pad is still pulsing its gamepad-mode entry (so the `b9.6` toggle completes even with
|
||||
/// no game input).
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if pad.in_mode_entry() || now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match open_transport(idx as u8) {
|
||||
Ok(t) => {
|
||||
self.pads[idx] = Some(t);
|
||||
self.state[idx] = SteamState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel handshake and forward rumble on the universal plane.
|
||||
/// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich
|
||||
/// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused.
|
||||
pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = pad.service() {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Find the evdev node for a kernel input device by exact name (e.g. `"Steam Deck"`).
|
||||
fn find_node(name: &str) -> Option<String> {
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").ok()?;
|
||||
for block in devs.split("\n\n") {
|
||||
if !block
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("N: Name=\"{name}\""))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for l in block.lines() {
|
||||
if let Some(h) = l.strip_prefix("H: Handlers=") {
|
||||
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
|
||||
return Some(format!("/dev/input/{ev}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the evdev's current key bitmap (`EVIOCGKEY`) and test whether `code` is down.
|
||||
fn key_is_down(node: &str, code: u16) -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let Ok(f) = std::fs::File::open(node) else {
|
||||
return false;
|
||||
};
|
||||
let mut bits = [0u8; 96];
|
||||
const EVIOCGKEY: libc::c_ulong = (2 << 30) | (96 << 16) | (0x45 << 8) | 0x18;
|
||||
// SAFETY: EVIOCGKEY copies the current key-state bitmap of the evdev behind the valid fd
|
||||
// `f` into `bits`; 96 bytes covers KEY_MAX/8, so the kernel never writes past the buffer.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), EVIOCGKEY, bits.as_mut_ptr()) };
|
||||
rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1
|
||||
}
|
||||
|
||||
/// Read the current value of an absolute axis (`EVIOCGABS`) — the first `i32` of `input_absinfo`.
|
||||
fn abs_value(node: &str, abs: u16) -> Option<i32> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let f = std::fs::File::open(node).ok()?;
|
||||
let mut info = [0u8; 24]; // struct input_absinfo { value, min, max, fuzz, flat, resolution }
|
||||
let req: libc::c_ulong =
|
||||
(2 << 30) | (24 << 16) | (0x45 << 8) | (0x40 + abs as libc::c_ulong);
|
||||
// SAFETY: EVIOCGABS fills the 24-byte input_absinfo for the valid evdev fd `f`; we read only
|
||||
// the leading i32 `value`. The buffer is exactly sizeof(input_absinfo), so no overflow.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, info.as_mut_ptr()) };
|
||||
(rc >= 0).then(|| i32::from_ne_bytes([info[0], info[1], info[2], info[3]]))
|
||||
}
|
||||
|
||||
/// On-box smoke test for the real backend: a `SteamDeckPad` must bind `hid-steam` (creating both
|
||||
/// the gamepad + IMU evdevs), enter `gamepad_mode` via the creation pulse, and land a held button
|
||||
/// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path —
|
||||
/// then tear the device down on drop. Touches `/dev/uhid`, so it is `#[ignore]`d in CI; run on a
|
||||
/// box with `hid-steam` + `input`-group access: `cargo test -p punktfunk-host -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||
fn backend_binds_and_input_flows() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
const BTN_A: u16 = 0x130;
|
||||
const ABS_HAT0X: u16 = 0x10; // left trackpad X
|
||||
let mut pad = SteamDeckPad::open(0).expect("open SteamDeckPad (/dev/uhid + input group?)");
|
||||
// Drive the full M3 wire path: build state through `from_gamepad` (BTN_A + the L4 back grip)
|
||||
// and `apply_rich` (a left-pad TouchpadEx contact), then hold it past MODE_ENTER (the b9.6
|
||||
// pulse), servicing the handshake.
|
||||
let mut st = SteamState::from_gamepad(gs::BTN_A | gs::BTN_PADDLE2, 0, 0, 0, 0, 0, 0);
|
||||
st.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: -8000,
|
||||
y: 9000,
|
||||
pressure: 0,
|
||||
});
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1200) {
|
||||
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 Deck"), "gamepad evdev not created");
|
||||
assert!(
|
||||
devs.contains("Steam Deck Motion Sensors"),
|
||||
"IMU evdev not created"
|
||||
);
|
||||
let node = find_node("Steam Deck").expect("gamepad evdev node");
|
||||
assert!(
|
||||
key_is_down(&node, BTN_A),
|
||||
"BTN_A not down — gamepad_mode entry or serialize failed"
|
||||
);
|
||||
// The left trackpad contact (TouchpadEx surface 1, gated on LPAD_TOUCH) reaches ABS_HAT0X.
|
||||
assert_eq!(
|
||||
abs_value(&node, ABS_HAT0X),
|
||||
Some(-8000),
|
||||
"left trackpad (TouchpadEx surface 1) did not reach ABS_HAT0X"
|
||||
);
|
||||
drop(pad);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
!devs.contains("Steam Deck Motion Sensors"),
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! Virtual Steam Deck via the USB **gadget** subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||
//! virtual-Deck transport Steam Input recognizes.
|
||||
//!
|
||||
//! The UHID [`super::steam_controller::SteamDeckPad`] binds the kernel `hid-steam` driver, but Steam's
|
||||
//! own controller driver filters the Deck's controller to USB **interface 2**, and a UHID device has no
|
||||
//! USB interface number (`Interface: -1`), so Steam enumerates it but never promotes it. This backend
|
||||
//! instead presents a *real* 3-interface USB Deck (mouse = interface 0, keyboard = 1, **controller =
|
||||
//! 2**) on a `dummy_hcd` loopback UDC, driven from userspace via `/dev/raw-gadget` so we can answer
|
||||
//! every control transfer (including the HID feature reports `f_hid` can't). Proven on a real Deck:
|
||||
//! hid-steam binds it, Steam reserves an XInput slot and emits an X-Box pad. Descriptors are captured
|
||||
//! verbatim from a physical Deck; see `packaging/linux/steam-deck-gadget/` for the original PoC + the
|
||||
//! USB-stack gotchas. **SteamOS-host only** (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships).
|
||||
//!
|
||||
//! The transport here is self-contained (libc + std); the report bytes it streams are produced by
|
||||
//! [`super::steam_proto`] in the wrapping backend.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::mem::size_of;
|
||||
use std::os::fd::RawFd;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
// ---- raw_gadget UAPI (mirrors linux/usb/raw_gadget.h; inlined like the C PoC) ----
|
||||
const UDC_NAME_MAX: usize = 128;
|
||||
|
||||
#[repr(C)]
|
||||
struct UsbRawInit {
|
||||
driver_name: [u8; UDC_NAME_MAX],
|
||||
device_name: [u8; UDC_NAME_MAX],
|
||||
speed: u8,
|
||||
}
|
||||
|
||||
// usb_raw_event { u32 type; u32 length; u8 data[]; } — we read it into a fixed buffer.
|
||||
const EVENT_HDR: usize = 8; // type + length
|
||||
const EVENT_BUF: usize = EVENT_HDR + 64; // setup packet (8) fits easily
|
||||
|
||||
// usb_raw_ep_io { u16 ep; u16 flags; u32 length; u8 data[]; }
|
||||
const EPIO_HDR: usize = 8;
|
||||
|
||||
// usb_endpoint_descriptor is 9 bytes in the kernel (audio bRefresh/bSynchAddress); EP_ENABLE wants it.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct UsbEndpointDescriptor {
|
||||
b_length: u8,
|
||||
b_descriptor_type: u8,
|
||||
b_endpoint_address: u8,
|
||||
bm_attributes: u8,
|
||||
w_max_packet_size: u16,
|
||||
b_interval: u8,
|
||||
b_refresh: u8,
|
||||
b_synch_address: u8,
|
||||
}
|
||||
|
||||
const fn ioc(dir: u64, nr: u64, size: usize) -> libc::c_ulong {
|
||||
((dir << 30) | ((size as u64) << 16) | ((b'U' as u64) << 8) | nr) as libc::c_ulong
|
||||
}
|
||||
const IOCTL_INIT: libc::c_ulong = ioc(1, 0, size_of::<UsbRawInit>());
|
||||
const IOCTL_RUN: libc::c_ulong = ioc(0, 1, 0);
|
||||
const IOCTL_EVENT_FETCH: libc::c_ulong = ioc(2, 2, EVENT_HDR); // size is the header; kernel copies more
|
||||
const IOCTL_EP0_WRITE: libc::c_ulong = ioc(1, 3, EPIO_HDR);
|
||||
const IOCTL_EP0_READ: libc::c_ulong = ioc(2 | 1, 4, EPIO_HDR); // _IOWR
|
||||
const IOCTL_EP_ENABLE: libc::c_ulong = ioc(1, 5, size_of::<UsbEndpointDescriptor>());
|
||||
const IOCTL_EP_WRITE: libc::c_ulong = ioc(1, 7, EPIO_HDR);
|
||||
const IOCTL_CONFIGURE: libc::c_ulong = ioc(0, 9, 0);
|
||||
const IOCTL_VBUS_DRAW: libc::c_ulong = ioc(1, 10, 4);
|
||||
const IOCTL_EP0_STALL: libc::c_ulong = ioc(0, 12, 0);
|
||||
|
||||
const USB_RAW_EVENT_CONNECT: u32 = 1;
|
||||
const USB_RAW_EVENT_CONTROL: u32 = 2;
|
||||
const USB_SPEED_HIGH: u8 = 3;
|
||||
|
||||
// Captured-from-hardware Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared
|
||||
// [`super::steam_proto`] module (single source of truth, also used by the usbip transport).
|
||||
use super::steam_proto::{
|
||||
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL,
|
||||
RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE,
|
||||
};
|
||||
|
||||
const DEV_DESC: [u8; 18] = [
|
||||
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
|
||||
0, 0, 0, 64, // class/sub/proto, bMaxPacketSize0
|
||||
0xDE, 0x28, 0x05, 0x12, // idVendor 28DE, idProduct 1205
|
||||
0x00, 0x03, // bcdDevice 3.00
|
||||
1, 2, 3, 1, // iManufacturer, iProduct, iSerial, bNumConfigurations
|
||||
];
|
||||
|
||||
const HID_DT: u8 = 0x21;
|
||||
const HID_RPT_DT: u8 = 0x22;
|
||||
|
||||
/// Assemble the 84-byte config descriptor: config + 3×(interface + HID + 7-byte endpoint).
|
||||
fn build_config() -> Vec<u8> {
|
||||
let mut c = Vec::with_capacity(84);
|
||||
// config descriptor (wTotalLength patched after)
|
||||
c.extend_from_slice(&[9, 2, 84, 0, 3, 1, 0, 0x80, 250]);
|
||||
// helper closures
|
||||
let iface = |n: u8, sub: u8, proto: u8| [9u8, 4, n, 0, 1, 3, sub, proto, 0];
|
||||
let hid = |rlen: u16, country: u8| {
|
||||
[
|
||||
9u8,
|
||||
HID_DT,
|
||||
0x10,
|
||||
0x01,
|
||||
country,
|
||||
1,
|
||||
HID_RPT_DT,
|
||||
(rlen & 0xff) as u8,
|
||||
(rlen >> 8) as u8,
|
||||
]
|
||||
};
|
||||
let ep = |addr: u8, mps: u16| [7u8, 5, addr, 0x03, (mps & 0xff) as u8, (mps >> 8) as u8, 4];
|
||||
// interface 0: mouse, EP 0x81
|
||||
c.extend_from_slice(&iface(0, 0, 2));
|
||||
c.extend_from_slice(&hid(RDESC_MOUSE.len() as u16, 0));
|
||||
c.extend_from_slice(&ep(0x81, 8));
|
||||
// interface 1: keyboard (boot), EP 0x82
|
||||
c.extend_from_slice(&iface(1, 1, 1));
|
||||
c.extend_from_slice(&hid(RDESC_KBD.len() as u16, 0));
|
||||
c.extend_from_slice(&ep(0x82, 8));
|
||||
// interface 2: controller, EP 0x83, bCountryCode 33
|
||||
c.extend_from_slice(&iface(2, 0, 0));
|
||||
c.extend_from_slice(&hid(RDESC_CTRL.len() as u16, 33));
|
||||
c.extend_from_slice(&ep(0x83, 64));
|
||||
debug_assert_eq!(c.len(), 84);
|
||||
c
|
||||
}
|
||||
|
||||
fn string_desc(idx: u8, serial: &str) -> Vec<u8> {
|
||||
if idx == 0 {
|
||||
return vec![4, 3, 0x09, 0x04]; // LANGID en-US
|
||||
}
|
||||
let s: &str = match idx {
|
||||
1 => "Valve Software",
|
||||
2 => "Steam Deck Controller",
|
||||
3 => serial,
|
||||
_ => "",
|
||||
};
|
||||
let mut v = vec![(2 + s.len() * 2) as u8, 3];
|
||||
for ch in s.encode_utf16() {
|
||||
v.push((ch & 0xff) as u8);
|
||||
v.push((ch >> 8) as u8);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ---- ioctl wrappers (the only unsafe surface for the raw_gadget UAPI; documented once) ----
|
||||
fn ioctl_ptr<T>(fd: RawFd, req: libc::c_ulong, arg: *const T) -> i32 {
|
||||
// SAFETY: `fd` is our open /dev/raw-gadget descriptor; `arg` points to a correctly-sized,
|
||||
// initialized argument for `req` (a raw_gadget UAPI struct or an owned usb_raw_ep_io buffer)
|
||||
// that lives for the duration of the call. `ioctl` is variadic, so passing a thin pointer is ABI-correct.
|
||||
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
|
||||
}
|
||||
fn ioctl_mut<T>(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> i32 {
|
||||
// SAFETY: as `ioctl_ptr`, but `arg` is a writable buffer the kernel fills for `req` (EVENT_FETCH / EP0_READ).
|
||||
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
|
||||
}
|
||||
fn ioctl_val(fd: RawFd, req: libc::c_ulong, val: libc::c_ulong) -> i32 {
|
||||
// SAFETY: `req` (VBUS_DRAW) takes an integer argument by value; `fd` is our descriptor.
|
||||
unsafe { libc::ioctl(fd, req as _, val) as i32 }
|
||||
}
|
||||
fn ioctl_none(fd: RawFd, req: libc::c_ulong) -> i32 {
|
||||
// SAFETY: `req` (RUN / CONFIGURE / EP0_STALL) takes no argument, but raw_gadget rejects a non-zero
|
||||
// `value` with EINVAL — pass an explicit 0 (an omitted vararg would be an indeterminate register).
|
||||
unsafe { libc::ioctl(fd, req as _, 0) as i32 }
|
||||
}
|
||||
|
||||
// ---- low-level ep0 helpers (operate on the shared fd) ----
|
||||
fn ep0_write(fd: RawFd, data: &[u8]) -> i32 {
|
||||
let mut buf = vec![0u8; EPIO_HDR + data.len()];
|
||||
buf[0..2].copy_from_slice(&0u16.to_ne_bytes()); // ep 0
|
||||
buf[4..8].copy_from_slice(&(data.len() as u32).to_ne_bytes());
|
||||
buf[EPIO_HDR..].copy_from_slice(data);
|
||||
ioctl_ptr(fd, IOCTL_EP0_WRITE, buf.as_ptr())
|
||||
}
|
||||
fn ep0_read(fd: RawFd, len: usize) -> (i32, Vec<u8>) {
|
||||
let mut buf = vec![0u8; EPIO_HDR + len.max(1)];
|
||||
buf[4..8].copy_from_slice(&(len as u32).to_ne_bytes());
|
||||
let r = ioctl_mut(fd, IOCTL_EP0_READ, buf.as_mut_ptr());
|
||||
let n = if r > 0 { r as usize } else { 0 };
|
||||
(r, buf[EPIO_HDR..EPIO_HDR + n.min(len.max(1))].to_vec())
|
||||
}
|
||||
/// Complete a no-data OUT control (status stage is an IN, handled by a zero-length read).
|
||||
fn ep0_ack(fd: RawFd) {
|
||||
ep0_read(fd, 0);
|
||||
}
|
||||
fn ep0_stall(fd: RawFd) {
|
||||
ioctl_none(fd, IOCTL_EP0_STALL);
|
||||
}
|
||||
|
||||
/// Owns the `/dev/raw-gadget` fd; closing it tears the device down.
|
||||
struct GadgetFd(RawFd);
|
||||
impl Drop for GadgetFd {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the fd we opened in `SteamDeckGadget::open` and own uniquely here.
|
||||
unsafe { libc::close(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
|
||||
/// closes the gadget (the kernel tears down the device).
|
||||
pub struct SteamDeckGadget {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
running: Arc<AtomicBool>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
_fd: Arc<GadgetFd>,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl SteamDeckGadget {
|
||||
/// Bind a virtual Deck on a fresh `dummy_hcd` UDC. `index` only varies the serial. Requires
|
||||
/// `dummy_hcd` + `raw_gadget` loaded and write access to `/dev/raw-gadget` (root on SteamOS).
|
||||
pub fn open(index: u8) -> Result<SteamDeckGadget> {
|
||||
// SAFETY: opening a constant NUL-terminated device path with O_RDWR; returns a fd or -1.
|
||||
let fd = unsafe { libc::open(c"/dev/raw-gadget".as_ptr(), libc::O_RDWR) };
|
||||
if fd < 0 {
|
||||
bail!(
|
||||
"open /dev/raw-gadget ({}) — is raw_gadget+dummy_hcd loaded and are we root?",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
let fd = Arc::new(GadgetFd(fd));
|
||||
let raw = fd.0;
|
||||
|
||||
// INIT against the dummy UDC, then RUN.
|
||||
// SAFETY: `UsbRawInit` is a plain-old-data struct (byte arrays + u8); all-zero is a valid value.
|
||||
let mut init: UsbRawInit = unsafe { std::mem::zeroed() };
|
||||
copy_cstr(&mut init.driver_name, "dummy_udc");
|
||||
copy_cstr(&mut init.device_name, "dummy_udc.0");
|
||||
init.speed = USB_SPEED_HIGH;
|
||||
if ioctl_ptr(raw, IOCTL_INIT, &init as *const _) < 0 {
|
||||
bail!("raw_gadget INIT: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
if ioctl_none(raw, IOCTL_RUN) < 0 {
|
||||
bail!("raw_gadget RUN: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let serial = deck_serial(index);
|
||||
let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(Default::default()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
||||
let configured = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Control thread: enumerate + answer every control transfer.
|
||||
let control = {
|
||||
let fd = fd.clone();
|
||||
let running = running.clone();
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let feedback = feedback.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-ctrl".into())
|
||||
.spawn(move || {
|
||||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||
})
|
||||
.context("spawn gadget control thread")?
|
||||
};
|
||||
// Stream thread: push the current report on the controller interrupt-IN endpoint.
|
||||
let stream = {
|
||||
let fd = fd.clone();
|
||||
let running = running.clone();
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let report = report.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-stream".into())
|
||||
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
|
||||
.context("spawn gadget stream thread")?
|
||||
};
|
||||
|
||||
Ok(SteamDeckGadget {
|
||||
report,
|
||||
feedback,
|
||||
running,
|
||||
threads: vec![control, stream],
|
||||
_fd: fd,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the 64-byte Deck state report streamed to the kernel.
|
||||
pub fn write_state(&mut self, st: &super::steam_proto::SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; 64];
|
||||
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any feedback (rumble) the kernel/Steam wrote to the device.
|
||||
pub fn service(&mut self) -> super::steam_proto::SteamFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SteamDeckGadget {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_cstr(dst: &mut [u8], s: &str) {
|
||||
let n = s.len().min(dst.len() - 1);
|
||||
dst[..n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
fn control_loop(
|
||||
fd: Arc<GadgetFd>,
|
||||
running: Arc<AtomicBool>,
|
||||
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
|
||||
configured: Arc<AtomicBool>,
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
) {
|
||||
let raw = fd.0;
|
||||
let cfg = build_config();
|
||||
let mut last_set: Vec<u8> = Vec::new();
|
||||
let mut evbuf = [0u8; EVENT_BUF];
|
||||
while running.load(Ordering::SeqCst) {
|
||||
// EVENT_FETCH: type(4) length(4) data[].
|
||||
evbuf[4..8].copy_from_slice(&(8u32).to_ne_bytes()); // request setup-sized payload
|
||||
let r = ioctl_mut(raw, IOCTL_EVENT_FETCH, evbuf.as_mut_ptr());
|
||||
if r < 0 {
|
||||
if running.load(Ordering::SeqCst) {
|
||||
// transient; brief backoff
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let etype = u32::from_ne_bytes([evbuf[0], evbuf[1], evbuf[2], evbuf[3]]);
|
||||
match etype {
|
||||
USB_RAW_EVENT_CONNECT => {}
|
||||
USB_RAW_EVENT_CONTROL => {
|
||||
let s = &evbuf[EVENT_HDR..EVENT_HDR + 8];
|
||||
let ctrl = Setup {
|
||||
bm_request_type: s[0],
|
||||
b_request: s[1],
|
||||
w_value: u16::from_le_bytes([s[2], s[3]]),
|
||||
w_index: u16::from_le_bytes([s[4], s[5]]),
|
||||
w_length: u16::from_le_bytes([s[6], s[7]]),
|
||||
};
|
||||
handle_control(
|
||||
raw,
|
||||
&ctrl,
|
||||
&cfg,
|
||||
&serial,
|
||||
unit_id,
|
||||
&ctrl_ep,
|
||||
&configured,
|
||||
&mut last_set,
|
||||
&feedback,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Setup {
|
||||
bm_request_type: u8,
|
||||
b_request: u8,
|
||||
w_value: u16,
|
||||
w_index: u16,
|
||||
w_length: u16,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_control(
|
||||
raw: RawFd,
|
||||
ctrl: &Setup,
|
||||
cfg: &[u8],
|
||||
serial: &str,
|
||||
unit_id: u32,
|
||||
ctrl_ep: &std::sync::atomic::AtomicI32,
|
||||
configured: &AtomicBool,
|
||||
last_set: &mut Vec<u8>,
|
||||
feedback: &Mutex<super::steam_proto::SteamFeedback>,
|
||||
) {
|
||||
let idx = (ctrl.w_index & 0xff) as u8;
|
||||
let type_class = ctrl.bm_request_type & 0x60;
|
||||
let wl = ctrl.w_length as usize;
|
||||
if type_class == 0x00 {
|
||||
// standard
|
||||
match ctrl.b_request {
|
||||
0x06 => {
|
||||
// GET_DESCRIPTOR
|
||||
let dt = (ctrl.w_value >> 8) as u8;
|
||||
let di = (ctrl.w_value & 0xff) as u8;
|
||||
let resp: Vec<u8> = match dt {
|
||||
1 => DEV_DESC.to_vec(),
|
||||
2 => cfg.to_vec(),
|
||||
3 => string_desc(di, serial),
|
||||
HID_RPT_DT => match idx {
|
||||
0 => RDESC_MOUSE.to_vec(),
|
||||
1 => RDESC_KBD.to_vec(),
|
||||
_ => RDESC_CTRL.to_vec(),
|
||||
},
|
||||
HID_DT => {
|
||||
// re-emit the interface's HID descriptor from the config blob (best effort)
|
||||
hid_desc_for(cfg, idx)
|
||||
}
|
||||
_ => {
|
||||
ep0_stall(raw);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let n = resp.len().min(wl);
|
||||
ep0_write(raw, &resp[..n]);
|
||||
}
|
||||
0x09 => {
|
||||
// SET_CONFIGURATION
|
||||
ioctl_val(raw, IOCTL_VBUS_DRAW, 0x32);
|
||||
ioctl_none(raw, IOCTL_CONFIGURE);
|
||||
enable_endpoints(raw, ctrl_ep);
|
||||
ep0_ack(raw);
|
||||
configured.store(true, Ordering::SeqCst);
|
||||
}
|
||||
0x0b => ep0_ack(raw), // SET_INTERFACE
|
||||
0x00 => {
|
||||
let st = 0u16;
|
||||
ep0_write(raw, &st.to_le_bytes());
|
||||
}
|
||||
_ => ep0_stall(raw),
|
||||
}
|
||||
} else if type_class == 0x20 {
|
||||
// HID class
|
||||
match ctrl.b_request {
|
||||
0x01 => {
|
||||
// GET_REPORT — serve the Deck feature reply for the last requested command.
|
||||
let resp = feature_reply(last_set, serial, unit_id);
|
||||
let n = resp.len().min(wl);
|
||||
ep0_write(raw, &resp[..n]);
|
||||
}
|
||||
0x09 => {
|
||||
// SET_REPORT — read the host's data; remember it + extract feedback.
|
||||
let (r, data) = ep0_read(raw, wl);
|
||||
if r > 0 {
|
||||
*last_set = data.clone();
|
||||
// parse_steam_output expects [report-id(0), cmd, …]; EP0 OUT data is [cmd, …].
|
||||
let mut framed = Vec::with_capacity(data.len() + 1);
|
||||
framed.push(0);
|
||||
framed.extend_from_slice(&data);
|
||||
let fb = super::steam_proto::parse_steam_output(&framed);
|
||||
if fb.rumble.is_some() {
|
||||
if let Ok(mut g) = feedback.lock() {
|
||||
*g = fb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0x0a | 0x0b => ep0_ack(raw), // SET_IDLE / SET_PROTOCOL
|
||||
0x03 => {
|
||||
ep0_write(raw, &[0u8]);
|
||||
} // GET_PROTOCOL
|
||||
_ => ep0_stall(raw),
|
||||
}
|
||||
} else {
|
||||
ep0_stall(raw);
|
||||
}
|
||||
}
|
||||
|
||||
fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec<u8> {
|
||||
// The HID descriptors live right after each interface descriptor in the config blob.
|
||||
// Offsets: cfg(9) | i0(9) h0(9) e0(7) | i1(9) h1(9) e1(7) | i2(9) h2(9) e2(7)
|
||||
let off = match idx {
|
||||
0 => 9 + 9,
|
||||
1 => 9 + 25 + 9,
|
||||
_ => 9 + 50 + 9,
|
||||
};
|
||||
cfg.get(off..off + 9)
|
||||
.map(|s| s.to_vec())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enable_endpoints(raw: RawFd, ctrl_ep: &std::sync::atomic::AtomicI32) {
|
||||
let mk = |addr: u8, mps: u16| UsbEndpointDescriptor {
|
||||
b_length: 7,
|
||||
b_descriptor_type: 5,
|
||||
b_endpoint_address: addr,
|
||||
bm_attributes: 0x03,
|
||||
w_max_packet_size: mps,
|
||||
b_interval: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let e0 = mk(0x81, 8);
|
||||
let e1 = mk(0x82, 8);
|
||||
let e2 = mk(0x83, 64);
|
||||
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e0 as *const _);
|
||||
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e1 as *const _);
|
||||
let h2 = ioctl_ptr(raw, IOCTL_EP_ENABLE, &e2 as *const _);
|
||||
ctrl_ep.store(h2, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn stream_loop(
|
||||
fd: Arc<GadgetFd>,
|
||||
running: Arc<AtomicBool>,
|
||||
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
|
||||
configured: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
) {
|
||||
let raw = fd.0;
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let ep = ctrl_ep.load(Ordering::SeqCst);
|
||||
if configured.load(Ordering::SeqCst) && ep >= 0 {
|
||||
let r = report
|
||||
.lock()
|
||||
.map(|g| *g)
|
||||
.unwrap_or_else(|_| neutral_deck_report());
|
||||
let mut buf = [0u8; EPIO_HDR + 64];
|
||||
buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes());
|
||||
buf[4..8].copy_from_slice(&(64u32).to_ne_bytes());
|
||||
buf[EPIO_HDR..].copy_from_slice(&r);
|
||||
// Blocks until the host polls the interrupt-IN endpoint; that's fine on its own thread.
|
||||
ioctl_ptr(raw, IOCTL_EP_WRITE, buf.as_ptr());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(8));
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort load of the gadget modules (SteamOS ships `dummy_hcd` + `raw_gadget`). Failures are
|
||||
/// ignored — the caller falls back to UHID if `/dev/raw-gadget` is then still unusable.
|
||||
pub fn ensure_modules() {
|
||||
for m in ["dummy_hcd", "raw_gadget"] {
|
||||
let _ = std::process::Command::new("modprobe").arg(m).status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to prefer the USB-gadget Deck over the UHID `SteamDeckPad` — the only transport Steam Input
|
||||
/// promotes (validated glass-to-glass on a Deck). Defaults **on for SteamOS** hosts (which ship the
|
||||
/// gadget modules + run Steam Input); off elsewhere, where the universal UHID path stays the default.
|
||||
/// `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it on/off. A Deck-as-host with a *physical* Deck never reaches
|
||||
/// here: `resolve_gamepad`'s conflict gate degrades `SteamDeck` → DualSense before the manager is built.
|
||||
pub fn gadget_preferred() -> bool {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_STEAM_GADGET") {
|
||||
return v == "1" || v.eq_ignore_ascii_case("true");
|
||||
}
|
||||
is_steamos()
|
||||
}
|
||||
|
||||
/// True on SteamOS-class hosts (`/etc/os-release` `ID=steamos`, or `ID_LIKE` naming it).
|
||||
fn is_steamos() -> bool {
|
||||
std::fs::read_to_string("/etc/os-release")
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.any(|l| l == "ID=steamos" || (l.starts_with("ID_LIKE=") && l.contains("steamos")))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
//! Virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, universal
|
||||
//! alternative to [`super::steam_gadget`] (`raw_gadget` + `dummy_hcd`, SteamOS-only).
|
||||
//!
|
||||
//! Like the gadget, this presents a *real* 3-interface USB Steam Deck (mouse = interface 0, keyboard
|
||||
//! = 1, **controller = 2**) — the interface-2 layout Steam's own driver filters on, so Steam Input
|
||||
//! promotes it (a UHID Deck, `Interface: -1`, never is). Unlike the gadget it needs no out-of-tree
|
||||
//! module: `vhci_hcd` is in-tree + signed on SteamOS, Bazzite, and ~every distro, loads under Secure
|
||||
//! Boot, and needs no MOK. A userspace [`usbip_sim`] server emulates the Deck; the local `vhci_hcd`
|
||||
//! attaches it. **Validated on Bazzite**: `vhci_hcd` enumerates the 3-interface Deck, `hid-steam`
|
||||
//! binds it, and Steam reserves an XInput slot — identical recognition to the gadget.
|
||||
//!
|
||||
//! The device model + the USB/IP protocol come from the vendored [`usbip_sim`] crate (the upstream
|
||||
//! `usbip` crate trimmed of its libusb host mode); the captured descriptors + the `0x83`/`0xAE`
|
||||
//! feature contract come from the shared [`super::steam_proto`] (one source of truth with the gadget).
|
||||
//!
|
||||
//! **Attach** is in-process by default (no external `usbip` CLI dependency — the production goal): we
|
||||
//! run the emulation server on a loopback TCP port, connect to it ourselves, perform the
|
||||
//! `OP_REQ_IMPORT` handshake, then hand the connected socket fd to `vhci_hcd` via its sysfs `attach`
|
||||
//! file. If anything in that path fails we fall back to the widely-packaged `usbip` CLI; if *that*
|
||||
//! also fails, [`open`](SteamDeckUsbip::open) returns `Err` and the caller degrades to UHID.
|
||||
|
||||
use super::steam_proto::{
|
||||
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, parse_steam_output,
|
||||
SteamFeedback, SteamState, RDESC_DECK_CTRL, RDESC_DECK_KBD, RDESC_DECK_MOUSE,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::any::Any;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use usbip_sim::{
|
||||
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer,
|
||||
Version,
|
||||
};
|
||||
|
||||
const STEAM_VENDOR: u16 = 0x28DE;
|
||||
const STEAMDECK_PRODUCT: u16 = 0x1205;
|
||||
/// The single device's USB/IP bus id (one device per server, so the fixed default is fine).
|
||||
const BUS_ID: &str = "0-0-0";
|
||||
/// The usbip default TCP port — the server must listen here for the `usbip` CLI fallback to attach.
|
||||
const USBIP_TCP_PORT: u16 = 3240;
|
||||
|
||||
/// Build the 9-byte HID class descriptor inserted between the interface and endpoint descriptors.
|
||||
fn hid_desc(report_len: usize, country: u8) -> Vec<u8> {
|
||||
let l = report_len as u16;
|
||||
#[rustfmt::skip]
|
||||
let d = vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8];
|
||||
d
|
||||
}
|
||||
|
||||
/// The Deck **controller** interface (vendor HID, interface 2): answers the HID feature reports
|
||||
/// (descriptor / `0x83` attributes / `0xAE` serial), streams the current 64-byte state on the
|
||||
/// interrupt-IN endpoint, and surfaces rumble written via SET_REPORT.
|
||||
#[derive(Debug)]
|
||||
struct ControllerHandler {
|
||||
/// The current 64-byte Deck input report, shared with [`SteamDeckUsbip::write_state`].
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
/// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`].
|
||||
feedback: Arc<Mutex<SteamFeedback>>,
|
||||
/// The host's last SET_REPORT command (drives [`feature_reply`]).
|
||||
last_set: Vec<u8>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
}
|
||||
|
||||
impl UsbInterfaceHandler for ControllerHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(RDESC_DECK_CTRL.len(), 33)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() {
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
// GET report descriptor (standard, interface recipient).
|
||||
(0x81, 0x06) if (setup.value >> 8) == 0x22 => RDESC_DECK_CTRL.to_vec(),
|
||||
// HID GET_REPORT (feature) — the Deck `0x83`/`0xAE` contract.
|
||||
(0xA1, 0x01) => feature_reply(&self.last_set, &self.serial, self.unit_id).to_vec(),
|
||||
// HID SET_REPORT — remember the command (for the next feature reply) + surface rumble.
|
||||
(0x21, 0x09) => {
|
||||
self.last_set = req.to_vec();
|
||||
// `parse_steam_output` expects `[report-id(0), cmd, …]`; EP0 OUT data is `[cmd, …]`.
|
||||
let mut framed = Vec::with_capacity(req.len() + 1);
|
||||
framed.push(0);
|
||||
framed.extend_from_slice(req);
|
||||
let fb = parse_steam_output(&framed);
|
||||
if fb.rumble.is_some() {
|
||||
if let Ok(mut g) = self.feedback.lock() {
|
||||
*g = fb;
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
||||
_ => vec![],
|
||||
})
|
||||
} else if let Direction::In = ep.direction() {
|
||||
// Interrupt-IN poll: return the current report. The vendored sim paces interrupt-IN by
|
||||
// bInterval (vhci_hcd does NOT throttle the server side), so this isn't a busy spin.
|
||||
let r = self
|
||||
.report
|
||||
.lock()
|
||||
.map(|g| *g)
|
||||
.unwrap_or_else(|_| neutral_deck_report());
|
||||
Ok(r.to_vec())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal idle HID interface (mouse / keyboard) — serves only its report descriptor.
|
||||
#[derive(Debug)]
|
||||
struct IdleHidHandler {
|
||||
report_desc: Vec<u8>,
|
||||
}
|
||||
impl UsbInterfaceHandler for IdleHidHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(self.report_desc.len(), 0)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_i: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_l: u32,
|
||||
setup: SetupPacket,
|
||||
_req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 {
|
||||
Ok(self.report_desc.clone())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed(
|
||||
h: impl UsbInterfaceHandler + Send + 'static,
|
||||
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
|
||||
Arc::new(Mutex::new(Box::new(h)))
|
||||
}
|
||||
fn ep(addr: u8, mps: u16) -> UsbEndpoint {
|
||||
UsbEndpoint {
|
||||
address: addr,
|
||||
attributes: 0x03, // interrupt
|
||||
max_packet_size: mps,
|
||||
interval: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the simulated 3-interface USB Deck. The controller handler shares `report` + `feedback`
|
||||
/// with the owning [`SteamDeckUsbip`].
|
||||
fn build_device(
|
||||
index: u8,
|
||||
report: &Arc<Mutex<[u8; 64]>>,
|
||||
feedback: &Arc<Mutex<SteamFeedback>>,
|
||||
) -> UsbDevice {
|
||||
let mut dev = UsbDevice::new(0); // one device per server; bus_id stays the default "0-0-0".
|
||||
dev.vendor_id = STEAM_VENDOR;
|
||||
dev.product_id = STEAMDECK_PRODUCT;
|
||||
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
|
||||
dev.device_bcd = Version::from(0x0300u16); // bcdDevice 3.00 (matches the gadget)
|
||||
dev.set_manufacturer_name("Valve Software");
|
||||
dev.set_product_name("Steam Deck Controller");
|
||||
dev.set_serial_number(&deck_serial(index));
|
||||
dev.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x02,
|
||||
Some("mouse"),
|
||||
vec![ep(0x81, 8)],
|
||||
boxed(IdleHidHandler {
|
||||
report_desc: RDESC_DECK_MOUSE.to_vec(),
|
||||
}),
|
||||
)
|
||||
.with_interface(
|
||||
0x03,
|
||||
0x01,
|
||||
0x01,
|
||||
Some("keyboard"),
|
||||
vec![ep(0x82, 8)],
|
||||
boxed(IdleHidHandler {
|
||||
report_desc: RDESC_DECK_KBD.to_vec(),
|
||||
}),
|
||||
)
|
||||
.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
Some("controller"),
|
||||
vec![ep(0x83, 64)],
|
||||
boxed(ControllerHandler {
|
||||
report: report.clone(),
|
||||
feedback: feedback.clone(),
|
||||
last_set: vec![],
|
||||
serial: deck_serial(index),
|
||||
unit_id: deck_unit_id(index),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Owns the emulation-server thread (a dedicated current-thread tokio runtime) and stops it on drop.
|
||||
/// Run on its own thread so `SteamDeckUsbip::open` works whether or not the caller is inside a tokio
|
||||
/// runtime (creating a runtime inside one would panic).
|
||||
struct ServerThread {
|
||||
stop: Arc<tokio::sync::Notify>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ServerThread {
|
||||
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
|
||||
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
|
||||
let stop = Arc::new(tokio::sync::Notify::new());
|
||||
let stop_t = stop.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-deck-usbip".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "usbip server runtime build failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(run_server(
|
||||
listener,
|
||||
Arc::new(UsbIpServer::new_simulated(vec![dev])),
|
||||
stop_t,
|
||||
));
|
||||
})
|
||||
.context("spawn usbip server thread")?;
|
||||
Ok(ServerThread {
|
||||
stop,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerThread {
|
||||
fn drop(&mut self) {
|
||||
self.stop.notify_one();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
|
||||
async fn run_server(
|
||||
listener: std::net::TcpListener,
|
||||
server: Arc<UsbIpServer>,
|
||||
stop: Arc<tokio::sync::Notify>,
|
||||
) {
|
||||
let listener = match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "usbip TcpListener::from_std failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop.notified() => break,
|
||||
r = listener.accept() => match r {
|
||||
Ok((mut sock, _)) => {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = usbip_sim::handler(&mut sock, server).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip accept error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
|
||||
/// disappears, Steam releases its slot) and stops the emulation server.
|
||||
pub struct SteamDeckUsbip {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<SteamFeedback>>,
|
||||
/// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
|
||||
vhci_port: u16,
|
||||
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
|
||||
/// only; the CLI hands its own fd to the kernel and exits, so this is `None` there).
|
||||
_client_sock: Option<TcpStream>,
|
||||
/// Emulation-server thread; dropped (stopped) after the detach.
|
||||
_server: ServerThread,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl SteamDeckUsbip {
|
||||
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial.
|
||||
/// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the
|
||||
/// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli`
|
||||
/// pins one path (for debugging).
|
||||
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
|
||||
ensure_modules();
|
||||
if vhci_base().is_none() {
|
||||
bail!(
|
||||
"vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?"
|
||||
);
|
||||
}
|
||||
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
|
||||
if mode.as_deref() != Some("cli") {
|
||||
match Self::open_in_process(index) {
|
||||
Ok(d) => return Ok(d),
|
||||
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open_via_cli(index)
|
||||
}
|
||||
|
||||
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
|
||||
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
|
||||
fn open_in_process(index: u8) -> Result<SteamDeckUsbip> {
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
||||
let dev = build_device(index, &report, &feedback);
|
||||
|
||||
// An ephemeral loopback port (avoids contending the usbip default with another pad).
|
||||
let listener =
|
||||
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.context("usbip server local_addr")?
|
||||
.port();
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.context("usbip listener set_nonblocking")?;
|
||||
let server = ServerThread::spawn(listener, dev)?;
|
||||
|
||||
// Connect to our own server and run the OP_REQ_IMPORT handshake.
|
||||
let mut sock = connect_loopback(port).context("connect to usbip server")?;
|
||||
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
|
||||
|
||||
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
|
||||
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
|
||||
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
|
||||
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
|
||||
sock.set_read_timeout(None).ok();
|
||||
sock.set_write_timeout(None).ok();
|
||||
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
|
||||
|
||||
tracing::info!(
|
||||
index,
|
||||
vhci_port,
|
||||
"virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)"
|
||||
);
|
||||
Ok(SteamDeckUsbip {
|
||||
report,
|
||||
feedback,
|
||||
vhci_port,
|
||||
_client_sock: Some(sock),
|
||||
_server: server,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
|
||||
/// port itself; we recover it by diffing the sysfs status).
|
||||
fn open_via_cli(index: u8) -> Result<SteamDeckUsbip> {
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
||||
let dev = build_device(index, &report, &feedback);
|
||||
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
|
||||
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.context("usbip listener set_nonblocking")?;
|
||||
let server = ServerThread::spawn(listener, dev)?;
|
||||
|
||||
let before = vhci_used_ports();
|
||||
usbip_attach_cli().context("usbip CLI attach")?;
|
||||
let vhci_port = wait_for_new_port(&before)
|
||||
.context("could not determine the vhci port the usbip CLI attached to")?;
|
||||
|
||||
tracing::info!(
|
||||
index,
|
||||
vhci_port,
|
||||
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)"
|
||||
);
|
||||
Ok(SteamDeckUsbip {
|
||||
report,
|
||||
feedback,
|
||||
vhci_port,
|
||||
_client_sock: None,
|
||||
_server: server,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the 64-byte Deck report streamed on the controller interrupt-IN endpoint.
|
||||
pub fn write_state(&mut self, st: &SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; 64];
|
||||
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any rumble feedback the kernel/Steam wrote to the device.
|
||||
pub fn service(&mut self) -> SteamFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SteamDeckUsbip {
|
||||
fn drop(&mut self) {
|
||||
// Detach the vhci port first (the kernel closes its end of the socket + tears down the
|
||||
// device); `_client_sock` + `_server` then drop, closing our side + stopping the server.
|
||||
if let Err(e) = vhci_detach(self.vhci_port) {
|
||||
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
|
||||
|
||||
const USBIP_VERSION: u16 = 0x0111;
|
||||
const OP_REQ_IMPORT: u16 = 0x8003;
|
||||
|
||||
/// Connect to our own loopback server, retrying briefly while the server thread comes up.
|
||||
fn connect_loopback(port: u16) -> Result<TcpStream> {
|
||||
let addr = ("127.0.0.1", port);
|
||||
let mut last = None;
|
||||
for _ in 0..50 {
|
||||
match TcpStream::connect(addr) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
return Ok(s);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Some(e);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"connect 127.0.0.1:{port}: {}",
|
||||
last.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Send `OP_REQ_IMPORT` for [`BUS_ID`] and read `OP_REP_IMPORT`, returning `(devid, speed)` parsed
|
||||
/// from the device record (the same `devid = bus_num<<16 | dev_num` + speed `vhci_hcd` wants). The
|
||||
/// whole 320-byte reply MUST be consumed here so the socket starts clean at the kernel's first
|
||||
/// `USBIP_CMD_SUBMIT`.
|
||||
fn import_handshake(sock: &mut TcpStream) -> Result<(u32, u32)> {
|
||||
// Bounded so a non-responsive server can't head-block the per-session input thread (this talks
|
||||
// to our own in-process loopback server, so a working handshake completes in well under a ms).
|
||||
sock.set_read_timeout(Some(Duration::from_secs(1))).ok();
|
||||
sock.set_write_timeout(Some(Duration::from_secs(1))).ok();
|
||||
|
||||
let mut req = Vec::with_capacity(40);
|
||||
req.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
req.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
|
||||
req.extend_from_slice(&0u32.to_be_bytes()); // status
|
||||
let mut busid = [0u8; 32];
|
||||
let b = BUS_ID.as_bytes();
|
||||
busid[..b.len()].copy_from_slice(b);
|
||||
req.extend_from_slice(&busid);
|
||||
sock.write_all(&req).context("send OP_REQ_IMPORT")?;
|
||||
|
||||
// Reply: version(2) code(2) status(4), then the 312-byte device record on success.
|
||||
let mut header = [0u8; 8];
|
||||
sock.read_exact(&mut header)
|
||||
.context("read OP_REP_IMPORT header")?;
|
||||
let status = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);
|
||||
if status != 0 {
|
||||
bail!("OP_REP_IMPORT refused (status={status}) — device {BUS_ID} not exported?");
|
||||
}
|
||||
let mut dev = [0u8; 312];
|
||||
sock.read_exact(&mut dev)
|
||||
.context("read OP_REP_IMPORT device record")?;
|
||||
// Device record layout: path[256], bus_id[32], bus_num(4 BE)@288, dev_num(4 BE)@292, speed(4)@296.
|
||||
let be = |o: usize| u32::from_be_bytes([dev[o], dev[o + 1], dev[o + 2], dev[o + 3]]);
|
||||
let bus_num = be(288);
|
||||
let dev_num = be(292);
|
||||
let speed = be(296);
|
||||
Ok(((bus_num << 16) | dev_num, speed))
|
||||
}
|
||||
|
||||
// ---- vhci_hcd sysfs plumbing ----
|
||||
|
||||
/// Best-effort load of `vhci_hcd` (in-tree + signed on SteamOS/Bazzite/most distros).
|
||||
pub fn ensure_modules() {
|
||||
let _ = Command::new("modprobe").arg("vhci_hcd").status();
|
||||
}
|
||||
|
||||
/// Run `usbip attach -r 127.0.0.1 -b 0-0-0`, bounded by a deadline so a hung CLI can't head-block
|
||||
/// the per-session input thread indefinitely (the caller runs this inline on that thread).
|
||||
fn usbip_attach_cli() -> Result<()> {
|
||||
let mut child = Command::new("usbip")
|
||||
.args(["attach", "-r", "127.0.0.1", "-b", BUS_ID])
|
||||
.spawn()
|
||||
.context("spawn `usbip attach` (is usbip-utils installed?)")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
match child.try_wait().context("wait on `usbip attach`")? {
|
||||
Some(st) if st.success() => return Ok(()),
|
||||
Some(st) => bail!("`usbip attach` exited with {st}"),
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bail!("`usbip attach` timed out (>6s) — killed");
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a usbip attach should be attempted at all. Default on (the universal Steam-promotable
|
||||
/// transport on non-SteamOS hosts); `PUNKTFUNK_STEAM_USBIP=0` forces it off, `=1` forces it on.
|
||||
/// [`open`](SteamDeckUsbip::open) still degrades gracefully if `vhci_hcd` turns out to be absent.
|
||||
pub fn usbip_preferred() -> bool {
|
||||
!matches!(
|
||||
std::env::var("PUNKTFUNK_STEAM_USBIP").ok().as_deref(),
|
||||
Some("0") | Some("false")
|
||||
)
|
||||
}
|
||||
|
||||
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
|
||||
fn vhci_base() -> Option<PathBuf> {
|
||||
for p in [
|
||||
"/sys/devices/platform/vhci_hcd.0",
|
||||
"/sys/devices/platform/vhci_hcd",
|
||||
] {
|
||||
let base = Path::new(p);
|
||||
if base.join("status").exists() {
|
||||
return Some(base.to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_status() -> Result<String> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
std::fs::read_to_string(base.join("status")).context("read vhci_hcd status")
|
||||
}
|
||||
|
||||
/// One parsed `status` row: `(port, hub_is_superspeed, sta)`. Handles both the modern
|
||||
/// `hub port sta …` and the legacy `port sta …` column layouts; returns `None` for header/blank rows.
|
||||
fn parse_status_row(line: &str) -> Option<(u16, bool, u32)> {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hub_ss, port_str, sta_str) = if t[0] == "hs" || t[0] == "ss" {
|
||||
(Some(t[0] == "ss"), *t.get(1)?, *t.get(2)?)
|
||||
} else if t[0].chars().all(|c| c.is_ascii_digit()) {
|
||||
(None, t[0], *t.get(1)?) // legacy: port sta …
|
||||
} else {
|
||||
return None; // header ("hub"/"prt"/"port" …)
|
||||
};
|
||||
let port = port_str.parse::<u16>().ok()?;
|
||||
let sta = sta_str.parse::<u32>().ok()?;
|
||||
Some((port, hub_ss.unwrap_or(false), sta))
|
||||
}
|
||||
|
||||
/// `sta == 4` is `VDEV_ST_NULL` (a free port).
|
||||
const VDEV_ST_NULL: u32 = 4;
|
||||
|
||||
/// Pick a free `vhci_hcd` port matching the device speed (`usbip_speed >= 5` ⇒ SuperSpeed hub).
|
||||
fn vhci_find_free_port(usbip_speed: u32) -> Result<u16> {
|
||||
let want_ss = usbip_speed >= 5;
|
||||
let status = read_status()?;
|
||||
for line in status.lines() {
|
||||
if let Some((port, is_ss, sta)) = parse_status_row(line) {
|
||||
if sta == VDEV_ST_NULL && is_ss == want_ss {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Speed-class match failed (legacy single-hub status): take any free port.
|
||||
for line in status.lines() {
|
||||
if let Some((port, _, sta)) = parse_status_row(line) {
|
||||
if sta == VDEV_ST_NULL {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("no free vhci_hcd port (all ports in use?)")
|
||||
}
|
||||
|
||||
/// Ports currently in use (`sta != VDEV_ST_NULL`) — snapshotted around a CLI attach to recover its port.
|
||||
fn vhci_used_ports() -> HashSet<u16> {
|
||||
read_status()
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.filter(|&(_, _, sta)| sta != VDEV_ST_NULL)
|
||||
.map(|(port, _, _)| port)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Poll the status file (briefly) for a port that became used since `before` — the one the CLI attached.
|
||||
fn wait_for_new_port(before: &HashSet<u16>) -> Result<u16> {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if let Some(p) = vhci_used_ports().difference(before).copied().min() {
|
||||
return Ok(p);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("no newly-attached vhci port appeared after `usbip attach`");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn vhci_attach(port: u16, sockfd: i32, devid: u32, speed: u32) -> Result<()> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
let line = format!("{port} {sockfd} {devid} {speed}");
|
||||
std::fs::write(base.join("attach"), line)
|
||||
.with_context(|| format!("write vhci_hcd attach (port {port}) — root?"))
|
||||
}
|
||||
|
||||
fn vhci_detach(port: u16) -> Result<()> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
std::fs::write(base.join("detach"), format!("{port}")).context("write vhci_hcd detach")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `status` parser handles the modern `hub port sta …` layout, the legacy `port sta …`
|
||||
/// layout, and skips header/blank lines — a slip here would mean attaching to a busy port.
|
||||
#[test]
|
||||
fn status_parser_handles_both_layouts() {
|
||||
// modern
|
||||
assert_eq!(
|
||||
parse_status_row("hs 0000 004 000 00000000 000000 0-0"),
|
||||
Some((0, false, 4))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_status_row("ss 0008 006 000 00000000 000000 0-0"),
|
||||
Some((8, true, 6))
|
||||
);
|
||||
// legacy (no hub column)
|
||||
assert_eq!(
|
||||
parse_status_row("0001 004 000 00000000 000000 0-0"),
|
||||
Some((1, false, 4))
|
||||
);
|
||||
// header / blank
|
||||
assert_eq!(
|
||||
parse_status_row("hub port sta spd dev sockfd local_busid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_status_row(""), None);
|
||||
}
|
||||
|
||||
/// A free HS port is preferred for an HS device; a free SS port for an SS device.
|
||||
#[test]
|
||||
fn free_port_selection_matches_speed() {
|
||||
let status = "hub port sta spd dev sockfd local_busid\n\
|
||||
hs 0000 006 000 00000000 000000 0-0\n\
|
||||
hs 0001 004 000 00000000 000000 0-0\n\
|
||||
ss 0008 004 000 00000000 000000 0-0\n";
|
||||
// Reuse the parser directly (vhci_find_free_port reads sysfs; test the selection logic).
|
||||
let hs = status
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && !is_ss)
|
||||
.map(|(p, _, _)| p);
|
||||
let ss = status
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && is_ss)
|
||||
.map(|(p, _, _)| p);
|
||||
assert_eq!(hs, Some(1));
|
||||
assert_eq!(ss, Some(8));
|
||||
}
|
||||
|
||||
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
|
||||
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
|
||||
#[test]
|
||||
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
|
||||
fn usbip_deck_binds_and_tears_down() {
|
||||
ensure_modules();
|
||||
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
|
||||
let st = SteamState::from_gamepad(punktfunk_core::input::gamepad::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(800) {
|
||||
pad.write_state(&st);
|
||||
let _ = pad.service();
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
devs.contains("Steam Deck"),
|
||||
"hid-steam did not bind the usbip Deck"
|
||||
);
|
||||
drop(pad);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
!devs.contains("Steam Deck Motion Sensors"),
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
//! Transport-independent Steam Controller / Steam Deck HID contract — the Steam analogue of
|
||||
//! [`super::dualsense_proto`]. The report descriptor, the command/feature IDs, the byte-exact
|
||||
//! Deck input-report serializer, the `XInput`/rich-input → state mappers, and the rumble-feedback
|
||||
//! parser. Pure logic, shared by the Linux UHID backend and (later) a Windows UMDF backend.
|
||||
//!
|
||||
//! **Layout source of truth:** the kernel `drivers/hid/hid-steam.c` `steam_do_deck_input_event`
|
||||
//! (+ `steam_do_deck_sensors_event`) — every offset/bit/sign below is transcribed verbatim from
|
||||
//! it and on-box-validated against kernel 7.0 (see `design/steam-controller-deck-support.md`).
|
||||
//! M0 proved the device binds + parses; M1 (here) makes the serializer byte-exact.
|
||||
//!
|
||||
//! Three load-bearing details the DualSense path does NOT have:
|
||||
//! * **report id 0 / unnumbered**: input reports are the raw 64 bytes starting `[0x01,0x00,0x09]`
|
||||
//! (no report-id prefix); FEATURE get/set reports DO carry a leading `0x00` report-id byte
|
||||
//! (`steam_send_report` does `memcpy(buf+1, cmd, …)`, `steam_recv_report` strips `buf[0]`).
|
||||
//! * **`gamepad_mode` gate**: `steam_do_deck_input_event` early-returns when
|
||||
//! `!gamepad_mode && lizard_mode` (the module param, default on). `gamepad_mode` starts false
|
||||
//! and TOGGLES when [`btn::STEAM_MENU_RIGHT`] (`b9.6`, the mode-switch) is held ~450 ms while
|
||||
//! no hidraw client is open. The backend enters gamepad mode at session start (pulse that bit,
|
||||
//! or load `hid_steam lizard_mode=0`) — see the backend, not this module.
|
||||
//! * **the `UHID_SET_REPORT` handshake** must be answered (DualSense omits it).
|
||||
#![allow(dead_code)] // Some of the full model is consumed only once the M2 backend + M3 wire land.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
|
||||
/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB`.
|
||||
pub const STEAM_VENDOR: u32 = 0x28DE;
|
||||
/// Steam Deck built-in controller (same PID on LCD + OLED).
|
||||
pub const STEAMDECK_PRODUCT: u32 = 0x1205;
|
||||
/// Classic Steam Controller, wired (report id 1 / `ID_CONTROLLER_STATE`; a later model).
|
||||
pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102;
|
||||
|
||||
/// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame.
|
||||
pub const STEAM_REPORT_LEN: usize = 64;
|
||||
|
||||
// Command IDs (drivers/hid/hid-steam.c), confirmed against the kernel source.
|
||||
pub const ID_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81;
|
||||
pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
pub const ID_SET_SETTINGS_VALUES: u8 = 0x87;
|
||||
pub const ID_LOAD_DEFAULT_SETTINGS: u8 = 0x8E;
|
||||
pub const ID_GET_DEVICE_INFO: u8 = 0xA1;
|
||||
pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
pub const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
/// Host→client feedback: `steam_haptic_rumble` emits report `[0xEB, 9, …]` (FF_RUMBLE → trackpad
|
||||
/// actuators / Deck motors). The Deck's rumble path; the classic SC also has `0x8F` pad pulses.
|
||||
pub const ID_TRIGGER_RUMBLE_CMD: u8 = 0xEB;
|
||||
pub const ID_TRIGGER_HAPTIC_PULSE: u8 = 0x8F;
|
||||
/// Input report message types: SC = `ID_CONTROLLER_STATE`, Deck = `ID_CONTROLLER_DECK_STATE`.
|
||||
pub const ID_CONTROLLER_STATE: u8 = 0x01;
|
||||
pub const ID_CONTROLLER_DECK_STATE: u8 = 0x09;
|
||||
|
||||
/// Which Steam device identity to present. M1 implements the Deck fully; the classic Controller
|
||||
/// (dual trackpads, report id 1, trackpad-only haptics) is a later identity behind the same path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SteamModel {
|
||||
Deck,
|
||||
Controller,
|
||||
}
|
||||
|
||||
impl SteamModel {
|
||||
pub fn product(self) -> u32 {
|
||||
match self {
|
||||
SteamModel::Deck => STEAMDECK_PRODUCT,
|
||||
SteamModel::Controller => STEAMCTRL_WIRED_PRODUCT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal vendor-defined HID report descriptor: one application collection with a 64-byte input
|
||||
/// report and a 64-byte feature report, both UNNUMBERED (report id 0). `hid-steam` is a raw-event
|
||||
/// driver, so the field layout is cosmetic — but `steam_probe` requires `hid_parse` to succeed AND
|
||||
/// a non-empty FEATURE report list (`steam_is_valve_interface`), so the feature item is mandatory.
|
||||
#[rustfmt::skip]
|
||||
pub const STEAMDECK_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8 bits)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) — the 64-byte state report
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0xB1, 0x02, // Feature (Data,Var,Abs) — makes steam_is_valve_interface() true
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Deck button bits, indexed in the `u64` packed across report bytes 8..16 — bit `(byte-8)*8 + bit`,
|
||||
/// transcribed verbatim from `steam_do_deck_input_event` (bytes 12 + 15 carry no buttons). Naming
|
||||
/// follows the physical Deck control; the trailing comment is the kernel `BTN_*` it maps to.
|
||||
pub mod btn {
|
||||
// byte 8
|
||||
pub const RT_FULL: u64 = 1 << 0; // BTN_TR2 — right trigger fully pressed
|
||||
pub const LT_FULL: u64 = 1 << 1; // BTN_TL2 — left trigger fully pressed
|
||||
pub const RB: u64 = 1 << 2; // BTN_TR — right shoulder
|
||||
pub const LB: u64 = 1 << 3; // BTN_TL — left shoulder
|
||||
pub const Y: u64 = 1 << 4;
|
||||
pub const B: u64 = 1 << 5;
|
||||
pub const X: u64 = 1 << 6;
|
||||
pub const A: u64 = 1 << 7;
|
||||
// byte 9
|
||||
pub const DPAD_UP: u64 = 1 << 8;
|
||||
pub const DPAD_RIGHT: u64 = 1 << 9;
|
||||
pub const DPAD_LEFT: u64 = 1 << 10;
|
||||
pub const DPAD_DOWN: u64 = 1 << 11;
|
||||
pub const VIEW: u64 = 1 << 12; // BTN_SELECT — "menu left" (View / Back)
|
||||
pub const STEAM: u64 = 1 << 13; // BTN_MODE — Steam logo button
|
||||
pub const MENU: u64 = 1 << 14; // BTN_START — "menu right" (Start / Options)
|
||||
pub const L5: u64 = 1 << 15; // BTN_GRIPL2 — left BOTTOM back grip
|
||||
// byte 10
|
||||
pub const R5: u64 = 1 << 16; // BTN_GRIPR2 — right BOTTOM back grip
|
||||
pub const LPAD_CLICK: u64 = 1 << 17; // BTN_THUMB — left pad pressed (click)
|
||||
pub const RPAD_CLICK: u64 = 1 << 18; // BTN_THUMB2 — right pad pressed (click)
|
||||
pub const LPAD_TOUCH: u64 = 1 << 19; // gates ABS_HAT0 (left pad coords)
|
||||
pub const RPAD_TOUCH: u64 = 1 << 20; // gates ABS_HAT1 (right pad coords)
|
||||
pub const L3: u64 = 1 << 22; // BTN_THUMBL — left joystick click
|
||||
// byte 11
|
||||
pub const R3: u64 = 1 << 26; // BTN_THUMBR — right joystick click
|
||||
// byte 13
|
||||
pub const L4: u64 = 1 << 41; // BTN_GRIPL — left TOP back grip
|
||||
pub const R4: u64 = 1 << 42; // BTN_GRIPR — right TOP back grip
|
||||
pub const LJOY_TOUCH: u64 = 1 << 46;
|
||||
pub const RJOY_TOUCH: u64 = 1 << 47;
|
||||
// byte 14
|
||||
pub const QAM: u64 = 1 << 50; // BTN_BASE — quick-access (…) button
|
||||
/// `b9.6` doubles as the mode-switch: held ~450 ms (no hidraw client) it toggles `gamepad_mode`.
|
||||
pub const STEAM_MENU_RIGHT: u64 = MENU;
|
||||
}
|
||||
|
||||
/// Full virtual Steam Deck controller state. All analog fields are stored as the RAW little-endian
|
||||
/// report values the kernel reads (so [`serialize_deck_state`] is a pure memcpy); the kernel applies
|
||||
/// its own sign conventions on top (`ABS_Y = -raw`, etc.) — see [`SteamState::from_gamepad`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SteamState {
|
||||
/// Packed button bits (see [`btn`]); occupies report bytes 8..16.
|
||||
pub buttons: u64,
|
||||
/// Left / right joystick, raw s16 (report 48/50/52/54). The kernel negates the Y axes.
|
||||
pub lx: i16,
|
||||
pub ly: i16,
|
||||
pub rx: i16,
|
||||
pub ry: i16,
|
||||
/// Left / right analog trigger, raw u16 (report 44/46 → ABS_HAT2Y/X).
|
||||
pub lt: u16,
|
||||
pub rt: u16,
|
||||
/// Left / right trackpad position, raw s16, centred 0 (report 16/18/20/22). Only surfaced by
|
||||
/// the kernel while the matching `*PAD_TOUCH` button bit is set.
|
||||
pub lpad_x: i16,
|
||||
pub lpad_y: i16,
|
||||
pub rpad_x: i16,
|
||||
pub rpad_y: i16,
|
||||
pub lpad_pressure: u16,
|
||||
pub rpad_pressure: u16,
|
||||
/// IMU, raw s16. `accel`/`gyro` are `[X, Y, Z]`; the kernel maps them to ABS_X/Z/Y + ABS_RX/RZ/RY
|
||||
/// (with Z/RZ negated) on the separate sensors evdev.
|
||||
pub accel: [i16; 3],
|
||||
pub gyro: [i16; 3],
|
||||
}
|
||||
|
||||
impl SteamState {
|
||||
pub fn neutral() -> SteamState {
|
||||
SteamState::default()
|
||||
}
|
||||
|
||||
/// Set/clear a button (or group) by its [`btn`] mask.
|
||||
pub fn press(&mut self, mask: u64, down: bool) {
|
||||
if down {
|
||||
self.buttons |= mask;
|
||||
} else {
|
||||
self.buttons &= !mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
pub fn 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,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
..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_LS_CLICK), btn::L3);
|
||||
set(&mut b, on(gs::BTN_RS_CLICK), btn::R3);
|
||||
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);
|
||||
// The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that
|
||||
// stands in for the DualSense touchpad — see apply_rich).
|
||||
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
|
||||
// Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four
|
||||
// Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button.
|
||||
set(&mut b, on(gs::BTN_PADDLE1), btn::R4);
|
||||
set(&mut b, on(gs::BTN_PADDLE2), btn::L4);
|
||||
set(&mut b, on(gs::BTN_PADDLE3), btn::R5);
|
||||
set(&mut b, on(gs::BTN_PADDLE4), btn::L5);
|
||||
set(&mut b, on(gs::BTN_MISC1), btn::QAM);
|
||||
s.buttons = b;
|
||||
s
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event into this state, preserving everything else. The single-pad
|
||||
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
|
||||
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
|
||||
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
match rich {
|
||||
RichInput::Touchpad { active, x, y, .. } => {
|
||||
self.press(btn::RPAD_TOUCH, active);
|
||||
// Normalized 0..=65535 (centre 32768) → the pad's centred s16 range.
|
||||
self.rpad_x = ((x as i32) - 32768) as i16;
|
||||
self.rpad_y = ((y as i32) - 32768) as i16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire carries DualSense-convention units (what every client capture emits); the
|
||||
// Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here.
|
||||
let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel);
|
||||
self.gyro = g;
|
||||
self.accel = a;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 =
|
||||
// left pad, anything else (0 single / 2 right) = right pad.
|
||||
if surface == 1 {
|
||||
self.press(btn::LPAD_TOUCH, touch);
|
||||
self.press(btn::LPAD_CLICK, click);
|
||||
self.lpad_x = x;
|
||||
self.lpad_y = y;
|
||||
} else {
|
||||
self.press(btn::RPAD_TOUCH, touch);
|
||||
self.press(btn::RPAD_CLICK, click);
|
||||
self.rpad_x = x;
|
||||
self.rpad_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the full Deck input report (`ID_CONTROLLER_DECK_STATE`) into the 64-byte unnumbered
|
||||
/// frame `hid-steam` parses. Pure + byte-exact against `steam_do_deck_input_event`; the report-id
|
||||
/// constant is `data[0]=0x01` (NOT a HID report id — this report is unnumbered).
|
||||
pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
|
||||
r.fill(0);
|
||||
r[0] = 0x01;
|
||||
r[1] = 0x00;
|
||||
r[2] = ID_CONTROLLER_DECK_STATE;
|
||||
r[3] = 0x3C; // payload length; the kernel ignores it
|
||||
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||
r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
|
||||
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
|
||||
r[18..20].copy_from_slice(&st.lpad_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());
|
||||
r[24..26].copy_from_slice(&st.accel[0].to_le_bytes()); // accel X → IMU ABS_X
|
||||
r[26..28].copy_from_slice(&st.accel[1].to_le_bytes()); // accel Y → IMU ABS_Z (kernel negates)
|
||||
r[28..30].copy_from_slice(&st.accel[2].to_le_bytes()); // accel Z → IMU ABS_Y
|
||||
r[30..32].copy_from_slice(&st.gyro[0].to_le_bytes()); // gyro X → IMU ABS_RX
|
||||
r[32..34].copy_from_slice(&st.gyro[1].to_le_bytes()); // gyro Y → IMU ABS_RZ (kernel negates)
|
||||
r[34..36].copy_from_slice(&st.gyro[2].to_le_bytes()); // gyro Z → IMU ABS_RY
|
||||
// 36..44 quaternion — left 0 (optional; the kernel does not surface it)
|
||||
r[44..46].copy_from_slice(&st.lt.to_le_bytes()); // left trigger → ABS_HAT2Y
|
||||
r[46..48].copy_from_slice(&st.rt.to_le_bytes()); // right trigger → ABS_HAT2X
|
||||
r[48..50].copy_from_slice(&st.lx.to_le_bytes()); // left joystick X → ABS_X
|
||||
r[50..52].copy_from_slice(&st.ly.to_le_bytes()); // left joystick Y → ABS_Y (kernel negates)
|
||||
r[52..54].copy_from_slice(&st.rx.to_le_bytes()); // right joystick X → ABS_RX
|
||||
r[54..56].copy_from_slice(&st.ry.to_le_bytes()); // right joystick Y → ABS_RY (kernel negates)
|
||||
r[56..58].copy_from_slice(&st.lpad_pressure.to_le_bytes());
|
||||
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
/// `1<=reply[1]<=21`, `reply[2]==0x01`. Non-fatal (a bad reply → the `"XXXXXXXXXX"` fallback).
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
buf[3] = ATTRIB_STR_UNIT_SERIAL;
|
||||
buf[4..4 + len].copy_from_slice(&bytes[..len]);
|
||||
buf
|
||||
}
|
||||
|
||||
/// One service pass's extracted feedback. Rumble rides the universal 0xCA plane (so any client
|
||||
/// feels it); the classic SC's trackpad-pulse haptics (`0x8F`) are a later, model-specific add.
|
||||
#[derive(Default, Debug, PartialEq, Eq)]
|
||||
pub struct SteamFeedback {
|
||||
/// `(low, high)` motor levels (left/strong, right/weak), if a rumble report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// Parse a feature/output report the kernel wrote to our device. The Steam feedback path is a
|
||||
/// FEATURE `SET_REPORT` whose wire data is `[0x00 report-id, cmd, len, …]`; `cmd == 0xEB`
|
||||
/// (`steam_haptic_rumble`) carries `[…, 0, intensity(2), left_speed(2), right_speed(2), gains(2)]`.
|
||||
/// We surface `(left_speed, right_speed)` as `(low, high)` for the 0xCA rumble plane.
|
||||
pub fn parse_steam_output(data: &[u8]) -> SteamFeedback {
|
||||
let mut fb = SteamFeedback::default();
|
||||
// data[0] is the stripped report-id byte (0); the command id follows.
|
||||
if data.len() >= 10 && data[1] == ID_TRIGGER_RUMBLE_CMD {
|
||||
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
|
||||
let left = le(6); // left_speed (report[5..7]) → low / strong motor
|
||||
let right = le(8); // right_speed (report[7..9]) → high / weak motor
|
||||
fb.rumble = Some((left, right));
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB
|
||||
// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]).
|
||||
//
|
||||
// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse =
|
||||
// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's
|
||||
// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The
|
||||
// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared
|
||||
// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci).
|
||||
// ===========================================================================================
|
||||
|
||||
/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81).
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_MOUSE: &[u8] = &[
|
||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
|
||||
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
|
||||
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
|
||||
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0];
|
||||
/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82).
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_KBD: &[u8] = &[
|
||||
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
|
||||
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
|
||||
0x75,0x08,0x95,0x06,0x81,0x00,0xc0];
|
||||
/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`,
|
||||
/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds.
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_CTRL: &[u8] = &[
|
||||
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];
|
||||
|
||||
/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`)
|
||||
/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index.
|
||||
pub fn deck_unit_id(index: u8) -> u32 {
|
||||
0x5046_0000 | index as u32
|
||||
}
|
||||
|
||||
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects
|
||||
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its
|
||||
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE`
|
||||
/// serial reply and the `0x83` unit-id attrs stay consistent.
|
||||
pub fn deck_serial(index: u8) -> String {
|
||||
format!("PFDK{:08X}", deck_unit_id(index))
|
||||
}
|
||||
|
||||
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
||||
/// real-USB transports stream until the first [`serialize_deck_state`] call updates it.
|
||||
pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
r[0] = 0x01;
|
||||
r[2] = ID_CONTROLLER_DECK_STATE;
|
||||
r[3] = 0x3C;
|
||||
r
|
||||
}
|
||||
|
||||
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB*
|
||||
/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE`
|
||||
/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn).
|
||||
/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via
|
||||
/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs.
|
||||
///
|
||||
/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB
|
||||
/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel
|
||||
/// strips.
|
||||
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] {
|
||||
let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
|
||||
r[0] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[1] = 0x2d;
|
||||
let attrs: [(u8, u32); 9] = [
|
||||
(0x01, 0x1205), // product id
|
||||
(0x02, 0),
|
||||
(0x0a, unit_id), // unit serial number (per-instance)
|
||||
(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;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
|
||||
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
|
||||
let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().clamp(1, 20);
|
||||
r[0] = ID_GET_STRING_ATTRIBUTE;
|
||||
r[1] = len as u8;
|
||||
r[2] = attr;
|
||||
r[3..3 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
_ => {
|
||||
// Settings read-back (e.g. 0x87): echo the host's last command + data.
|
||||
let n = last_set.len().min(STEAM_REPORT_LEN);
|
||||
r[..n].copy_from_slice(&last_set[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_input_and_feature_reports() {
|
||||
assert!(
|
||||
STEAMDECK_RDESC.contains(&0xB1),
|
||||
"missing Feature main item — steam_is_valve_interface() would fail"
|
||||
);
|
||||
assert!(STEAMDECK_RDESC.contains(&0x81), "missing Input main item");
|
||||
assert_eq!(
|
||||
*STEAMDECK_RDESC.last().unwrap(),
|
||||
0xC0,
|
||||
"unterminated collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every analog field lands at the exact offset `steam_do_deck_input_event` reads, the header is
|
||||
/// what `steam_raw_event` requires, and the buttons pack into bytes 8..16 (12+15 zero). A
|
||||
/// one-byte slip here turns the whole controller into noise.
|
||||
#[test]
|
||||
fn serialize_is_byte_exact() {
|
||||
let mut st = SteamState::neutral();
|
||||
st.buttons = btn::A | btn::L4 | btn::R5 | btn::QAM;
|
||||
st.lx = 0x1122;
|
||||
st.ly = 0x3344;
|
||||
st.rx = 0x5566;
|
||||
st.ry = 0x778;
|
||||
st.lt = 0xABCD;
|
||||
st.rt = 0xEF01;
|
||||
st.lpad_x = 0x0A0B;
|
||||
st.lpad_y = 0x0C0D;
|
||||
st.rpad_x = 0x0E0F;
|
||||
st.rpad_y = 0x1011;
|
||||
st.accel = [0x0102, 0x0304, 0x0506];
|
||||
st.gyro = [0x0708, 0x090A, 0x0B0C];
|
||||
st.lpad_pressure = 0x1314;
|
||||
st.rpad_pressure = 0x1516;
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, &st, 0xAABB_CCDD);
|
||||
assert_eq!(&r[0..4], &[0x01, 0x00, 0x09, 0x3C]);
|
||||
assert_eq!(&r[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]); // seq LE
|
||||
// buttons: A=bit7 (byte8), L4=bit41 (byte13.1), R5=bit16 (byte10.0), QAM=bit50 (byte14.2).
|
||||
assert_eq!(r[8], 0x80); // A
|
||||
assert_eq!(r[10], 0x01); // R5
|
||||
assert_eq!(r[12], 0x00); // unused button byte
|
||||
assert_eq!(r[13], 0x02); // L4 (bit 1)
|
||||
assert_eq!(r[14], 0x04); // QAM (bit 2)
|
||||
assert_eq!(r[15], 0x00); // unused button byte
|
||||
assert_eq!(&r[16..18], &0x0A0Bi16.to_le_bytes()); // lpad X
|
||||
assert_eq!(&r[20..22], &0x0E0Fi16.to_le_bytes()); // rpad X
|
||||
assert_eq!(&r[24..26], &0x0102i16.to_le_bytes()); // accel X
|
||||
assert_eq!(&r[26..28], &0x0304i16.to_le_bytes()); // accel Y
|
||||
assert_eq!(&r[28..30], &0x0506i16.to_le_bytes()); // accel Z
|
||||
assert_eq!(&r[30..32], &0x0708i16.to_le_bytes()); // gyro X
|
||||
assert_eq!(&r[44..46], &0xABCDu16.to_le_bytes()); // left trigger
|
||||
assert_eq!(&r[46..48], &0xEF01u16.to_le_bytes()); // right trigger
|
||||
assert_eq!(&r[48..50], &0x1122i16.to_le_bytes()); // left joy X
|
||||
assert_eq!(&r[50..52], &0x3344i16.to_le_bytes()); // left joy Y
|
||||
assert_eq!(&r[52..54], &0x5566i16.to_le_bytes()); // right joy X
|
||||
assert_eq!(&r[56..58], &0x1314u16.to_le_bytes()); // left pad pressure
|
||||
assert_eq!(&r[58..60], &0x1516u16.to_le_bytes()); // right pad pressure
|
||||
}
|
||||
|
||||
/// `from_gamepad` sets the right Deck bits + scales triggers, and a touched flag is merged when
|
||||
/// a trackpad contact arrives via `apply_rich`.
|
||||
#[test]
|
||||
fn from_gamepad_and_rich_mapping() {
|
||||
let s = SteamState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_LB,
|
||||
1000,
|
||||
-2000,
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::A, 0);
|
||||
assert_ne!(s.buttons & btn::MENU, 0);
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
let mut s = SteamState::neutral();
|
||||
s.apply_rich(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 65535,
|
||||
y: 0,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
||||
assert_eq!(s.rpad_y, -32768); // 0-32768
|
||||
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||
s.apply_rich(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [1000, -2000, 0],
|
||||
accel: [10000, -5000, 0],
|
||||
});
|
||||
assert_eq!(s.gyro, [800, -1600, 0]);
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (signed coords pass straight through).
|
||||
#[test]
|
||||
fn back_buttons_and_dual_trackpad_mapping() {
|
||||
let s = SteamState::from_gamepad(
|
||||
gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4
|
||||
assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4
|
||||
assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5
|
||||
assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5
|
||||
assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM
|
||||
|
||||
let mut s = SteamState::neutral();
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: -5000,
|
||||
y: 6000,
|
||||
pressure: 100,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
|
||||
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
|
||||
assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000));
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 7000,
|
||||
y: -8000,
|
||||
pressure: 0,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000));
|
||||
}
|
||||
|
||||
/// 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…]`.
|
||||
#[test]
|
||||
fn serial_reply_has_stripped_prefix() {
|
||||
let r = serial_reply("PUNKTFUNK01");
|
||||
assert_eq!(r[0], 0x00); // report id, stripped by steam_recv_report
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); // becomes reply[0] after strip
|
||||
assert!((1..=21).contains(&r[2]));
|
||||
assert_eq!(r[3], ATTRIB_STR_UNIT_SERIAL);
|
||||
assert_eq!(&r[4..4 + r[2] as usize], b"PUNKTFUNK01");
|
||||
}
|
||||
|
||||
/// A `0xEB` rumble feature report parses to `(left_speed, right_speed)`; other commands don't.
|
||||
#[test]
|
||||
fn parse_rumble_feedback() {
|
||||
// [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gains(2)]
|
||||
let mut d = vec![0u8; 12];
|
||||
d[1] = ID_TRIGGER_RUMBLE_CMD;
|
||||
d[2] = 9;
|
||||
d[6..8].copy_from_slice(&0x8000u16.to_le_bytes()); // left_speed
|
||||
d[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); // right_speed
|
||||
assert_eq!(parse_steam_output(&d).rumble, Some((0x8000, 0x4000)));
|
||||
|
||||
let mut d = vec![0u8; 12];
|
||||
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
|
||||
assert_eq!(parse_steam_output(&d).rumble, None);
|
||||
}
|
||||
|
||||
/// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply
|
||||
/// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the
|
||||
/// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the
|
||||
/// gamepad-evdev churn (Steam re-probing).
|
||||
#[test]
|
||||
fn deck_feature_reply_contract() {
|
||||
let serial = deck_serial(0);
|
||||
let unit_id = deck_unit_id(0);
|
||||
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id
|
||||
assert_eq!(serial.len(), 12);
|
||||
|
||||
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
||||
let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id);
|
||||
assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES);
|
||||
assert_eq!(r[1], 0x2d);
|
||||
assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…)
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([r[13], r[14], r[15], r[16]]),
|
||||
unit_id,
|
||||
"unit serial attribute must carry the per-instance unit id"
|
||||
);
|
||||
|
||||
// 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…].
|
||||
let r = feature_reply(
|
||||
&[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL],
|
||||
&serial,
|
||||
unit_id,
|
||||
);
|
||||
assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(r[1] as usize, serial.len());
|
||||
assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL);
|
||||
assert_eq!(&r[3..3 + serial.len()], serial.as_bytes());
|
||||
|
||||
// Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks).
|
||||
assert_ne!(deck_unit_id(0), deck_unit_id(1));
|
||||
assert_ne!(deck_serial(0), deck_serial(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved
|
||||
//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a
|
||||
//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the
|
||||
//! Deck backend itself needs.
|
||||
//!
|
||||
//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g.
|
||||
//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure +
|
||||
//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles
|
||||
//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Where the four Steam back grips go on a backend with no native back-button HID slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PaddleFallback {
|
||||
/// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire
|
||||
/// buttons the user didn't ask for. Set the env to map them instead.
|
||||
#[default]
|
||||
Drop,
|
||||
/// L4/L5 → left-stick click, R4/R5 → right-stick click.
|
||||
StickClicks,
|
||||
/// L4/L5 → left bumper, R4/R5 → right bumper.
|
||||
Shoulders,
|
||||
}
|
||||
|
||||
/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RemapConfig {
|
||||
pub paddles: PaddleFallback,
|
||||
}
|
||||
|
||||
impl RemapConfig {
|
||||
/// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults).
|
||||
pub fn from_env() -> RemapConfig {
|
||||
std::env::var("PUNKTFUNK_STEAM_REMAP")
|
||||
.map(|s| RemapConfig::parse(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]).
|
||||
pub fn parse(s: &str) -> RemapConfig {
|
||||
let mut cfg = RemapConfig::default();
|
||||
for kv in s.split([',', ';']) {
|
||||
let mut it = kv.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (it.next(), it.next()) {
|
||||
if k.trim().eq_ignore_ascii_case("paddles") {
|
||||
cfg.paddles = match v.trim().to_ascii_lowercase().as_str() {
|
||||
"stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks,
|
||||
"shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders,
|
||||
_ => PaddleFallback::Drop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with
|
||||
/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
|
||||
let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5
|
||||
let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5
|
||||
buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4);
|
||||
let (lbit, rbit) = match policy {
|
||||
PaddleFallback::Drop => return buttons,
|
||||
PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK),
|
||||
PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB),
|
||||
};
|
||||
if left {
|
||||
buttons |= lbit;
|
||||
}
|
||||
if right {
|
||||
buttons |= rbit;
|
||||
}
|
||||
buttons
|
||||
}
|
||||
|
||||
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
|
||||
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
|
||||
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
|
||||
const GYRO_NUM: i32 = 16;
|
||||
const GYRO_DEN: i32 = 20;
|
||||
const ACCEL_NUM: i32 = 16384;
|
||||
const ACCEL_DEN: i32 = 10000;
|
||||
|
||||
fn scale(v: i16, num: i32, den: i32) -> i16 {
|
||||
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
|
||||
}
|
||||
|
||||
/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units.
|
||||
pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) {
|
||||
(
|
||||
gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)),
|
||||
accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_paddle_policy() {
|
||||
assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=stickclicks").paddles,
|
||||
PaddleFallback::StickClicks
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("foo=bar; paddles = Shoulders").paddles,
|
||||
PaddleFallback::Shoulders
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=nonsense").paddles,
|
||||
PaddleFallback::Drop
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_paddles_maps_and_clears() {
|
||||
// All four grips set + a real A button.
|
||||
let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4;
|
||||
// Drop: paddle bits cleared, A preserved, nothing added.
|
||||
assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A);
|
||||
// StickClicks: left grips → L3, right grips → R3.
|
||||
assert_eq!(
|
||||
fold_paddles(b, PaddleFallback::StickClicks),
|
||||
gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK
|
||||
);
|
||||
// Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders.
|
||||
assert_eq!(
|
||||
fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders),
|
||||
gs::BTN_LB
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_rescale_to_deck_units() {
|
||||
// gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384.
|
||||
let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]);
|
||||
assert_eq!(g, [800, -1600, 0]);
|
||||
assert_eq!(a, [16384, -8192, 0]);
|
||||
// Saturates rather than wraps.
|
||||
let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]);
|
||||
assert_eq!(a[0], i16::MAX);
|
||||
assert_eq!(a[1], i16::MIN);
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,9 @@ impl DualSenseWindowsManager {
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -409,6 +411,26 @@ impl DualSenseWindowsManager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,9 @@ impl DualShock4WindowsManager {
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
@@ -210,6 +212,26 @@ impl DualShock4WindowsManager {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ use windows::Win32::System::Memory::{
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
|
||||
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the
|
||||
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the
|
||||
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends'
|
||||
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
||||
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps
|
||||
/// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three
|
||||
/// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
||||
///
|
||||
/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and
|
||||
/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's
|
||||
/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken)
|
||||
/// assumption the driver needed a restricted token's broad access — letting any local user
|
||||
/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel
|
||||
/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is
|
||||
/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is
|
||||
/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes.
|
||||
pub(super) struct Shm {
|
||||
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
|
||||
_handle: OwnedHandle,
|
||||
@@ -40,7 +48,7 @@ impl Shm {
|
||||
// exit — acceptable for a host-lifetime object).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
|
||||
@@ -394,6 +394,12 @@ struct ArmNativePairing {
|
||||
/// Window length in seconds (default 120; clamped to 15–600).
|
||||
#[schema(example = 120)]
|
||||
ttl_secs: Option<u32>,
|
||||
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
|
||||
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
|
||||
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
|
||||
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// A paired native (punktfunk/1) client.
|
||||
@@ -879,8 +885,21 @@ async fn arm_native_pairing(
|
||||
);
|
||||
};
|
||||
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
|
||||
let _pin = np.arm(std::time::Duration::from_secs(ttl as u64));
|
||||
tracing::info!(ttl_secs = ttl, "management API: native pairing armed");
|
||||
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
|
||||
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
|
||||
let bound = req
|
||||
.fingerprint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let bound_to_device = bound.is_some();
|
||||
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
|
||||
tracing::info!(
|
||||
ttl_secs = ttl,
|
||||
bound_to_device,
|
||||
"management API: native pairing armed"
|
||||
);
|
||||
Json(native_status(&st)).into_response()
|
||||
}
|
||||
|
||||
@@ -1975,8 +1994,8 @@ mod tests {
|
||||
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||
|
||||
// Two devices knock (what the QUIC gate records); they appear in the list.
|
||||
np.note_pending("Enrico's MacBook", "aa11");
|
||||
np.note_pending("device bb22cc33", "bb22");
|
||||
np.note_pending("Enrico's MacBook", "aa11", None);
|
||||
np.note_pending("device bb22cc33", "bb22", None);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 2);
|
||||
assert_eq!(b[0]["name"], "Enrico's MacBook");
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! armed on demand for a short window — rather than accepting one.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -42,10 +43,29 @@ struct PairedState {
|
||||
|
||||
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
|
||||
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
|
||||
///
|
||||
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
|
||||
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
|
||||
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
|
||||
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
|
||||
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
|
||||
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
|
||||
#[derive(Default)]
|
||||
struct Armed {
|
||||
pin: Option<String>,
|
||||
expires_at: Option<Instant>,
|
||||
bound_fp: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of resolving the armed PIN for a specific client fingerprint ([`NativePairing::pin_for_attempt`]).
|
||||
pub enum PinAttempt {
|
||||
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
|
||||
Disarmed,
|
||||
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
|
||||
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
|
||||
BoundToOther,
|
||||
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
|
||||
Pin(String),
|
||||
}
|
||||
|
||||
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
|
||||
@@ -57,6 +77,13 @@ struct Pending {
|
||||
name: String,
|
||||
fp_hex: String,
|
||||
requested_at: Instant,
|
||||
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
|
||||
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
|
||||
src_ip: Option<IpAddr>,
|
||||
/// True while a connection is held open in [`NativePairing::wait_for_decision`] for this knock.
|
||||
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
||||
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
||||
parked: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -94,6 +121,10 @@ pub enum PairingDecision {
|
||||
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
|
||||
const PENDING_CAP: usize = 32;
|
||||
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
|
||||
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
|
||||
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
|
||||
const MAX_PENDING_PER_IP: usize = 4;
|
||||
|
||||
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
|
||||
/// pending-approval queue.
|
||||
@@ -209,6 +240,7 @@ impl NativePairing {
|
||||
Armed {
|
||||
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
|
||||
expires_at: None,
|
||||
bound_fp: None,
|
||||
}
|
||||
} else {
|
||||
Armed::default()
|
||||
@@ -221,16 +253,43 @@ impl NativePairing {
|
||||
})
|
||||
}
|
||||
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`. Returns the PIN to display.
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt
|
||||
/// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device
|
||||
/// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9).
|
||||
pub fn arm(&self, ttl: Duration) -> String {
|
||||
self.arm_for(ttl, None)
|
||||
}
|
||||
|
||||
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
|
||||
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
|
||||
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
|
||||
pub fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
|
||||
let pin = random_pin();
|
||||
*self.arm.lock().unwrap() = Armed {
|
||||
pin: Some(pin.clone()),
|
||||
expires_at: Some(Instant::now() + ttl),
|
||||
bound_fp,
|
||||
};
|
||||
pin
|
||||
}
|
||||
|
||||
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
|
||||
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
|
||||
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
|
||||
pub fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
|
||||
let mut arm = self.arm.lock().unwrap();
|
||||
Self::expire(&mut arm);
|
||||
match &arm.pin {
|
||||
None => PinAttempt::Disarmed,
|
||||
Some(pin) => match &arm.bound_fp {
|
||||
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
|
||||
PinAttempt::BoundToOther
|
||||
}
|
||||
_ => PinAttempt::Pin(pin.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Disarm pairing (no new ceremonies accepted).
|
||||
pub fn disarm(&self) {
|
||||
*self.arm.lock().unwrap() = Armed::default();
|
||||
@@ -342,11 +401,30 @@ impl NativePairing {
|
||||
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||
}
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same
|
||||
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
|
||||
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
|
||||
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
|
||||
pub fn note_pending(&self, name: &str, fp_hex: &str) {
|
||||
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
||||
/// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
|
||||
/// evict it under load); only if every candidate is parked does it fall back to the oldest of
|
||||
/// those (#13). Returns the index, or `None` if there's nothing to evict.
|
||||
fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
|
||||
let pick = |allow_parked: bool| {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
|
||||
.filter(|(_, p)| allow_parked || !p.parked)
|
||||
.min_by_key(|(_, p)| p.requested_at)
|
||||
.map(|(i, _)| i)
|
||||
};
|
||||
pick(false).or_else(|| pick(true))
|
||||
}
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list). A
|
||||
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
||||
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
||||
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
||||
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
||||
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) {
|
||||
let name = sanitize_device_name(name, fp_hex);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
@@ -357,19 +435,31 @@ impl NativePairing {
|
||||
{
|
||||
p.requested_at = Instant::now();
|
||||
p.name = name;
|
||||
if p.src_ip.is_none() {
|
||||
p.src_ip = src_ip;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if pending.items.len() >= PENDING_CAP {
|
||||
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
|
||||
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
|
||||
if let Some(at) = pending
|
||||
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
||||
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
||||
if let Some(ip) = src_ip {
|
||||
if pending
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, p)| p.requested_at)
|
||||
.map(|(i, _)| i)
|
||||
.filter(|p| p.src_ip == Some(ip))
|
||||
.count()
|
||||
>= MAX_PENDING_PER_IP
|
||||
{
|
||||
pending.items.remove(at);
|
||||
if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
|
||||
// recency after in-place refreshes, so pick explicitly).
|
||||
if pending.items.len() >= PENDING_CAP {
|
||||
if let Some(i) = Self::evict_index(&pending.items, None) {
|
||||
pending.items.remove(i);
|
||||
}
|
||||
}
|
||||
let id = pending.next_id;
|
||||
@@ -379,9 +469,24 @@ impl NativePairing {
|
||||
name,
|
||||
fp_hex: fp_hex.to_string(),
|
||||
requested_at: Instant::now(),
|
||||
src_ip,
|
||||
parked: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
||||
/// A parked entry is protected from eviction under load (#13).
|
||||
fn set_parked(&self, fp_hex: &str, parked: bool) {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if let Some(p) = pending
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
{
|
||||
p.parked = parked;
|
||||
}
|
||||
}
|
||||
|
||||
/// The devices currently awaiting approval (for the management API).
|
||||
pub fn pending(&self) -> Vec<PendingRequest> {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
@@ -462,6 +567,23 @@ impl NativePairing {
|
||||
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and
|
||||
/// streams with no reconnect (delegated approval, roadmap §8b-1).
|
||||
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
|
||||
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
||||
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
||||
// exit path by the guard's Drop.
|
||||
self.set_parked(fp_hex, true);
|
||||
struct ParkGuard<'a> {
|
||||
np: &'a NativePairing,
|
||||
fp: &'a str,
|
||||
}
|
||||
impl Drop for ParkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.np.set_parked(self.fp, false);
|
||||
}
|
||||
}
|
||||
let _park = ParkGuard {
|
||||
np: self,
|
||||
fp: fp_hex,
|
||||
};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
|
||||
@@ -548,8 +670,8 @@ mod tests {
|
||||
|
||||
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
|
||||
// instead of duplicating.
|
||||
np.note_pending("device aa11", "AA11");
|
||||
np.note_pending("Bedroom TV", "aa11");
|
||||
np.note_pending("device aa11", "AA11", None);
|
||||
np.note_pending("Bedroom TV", "aa11", None);
|
||||
let pend = np.pending();
|
||||
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
|
||||
assert_eq!(pend[0].name, "Bedroom TV");
|
||||
@@ -562,7 +684,7 @@ mod tests {
|
||||
assert!(!np.is_paired("aa11"));
|
||||
|
||||
// Approve pairs the fingerprint (operator label wins) and clears the entry.
|
||||
np.note_pending("device bb22", "BB22");
|
||||
np.note_pending("device bb22", "BB22", None);
|
||||
let id = np.pending()[0].id;
|
||||
assert!(
|
||||
np.approve_pending(9999, None).unwrap().is_none(),
|
||||
@@ -578,8 +700,11 @@ mod tests {
|
||||
assert_eq!(np.list()[0].name, "Living Room");
|
||||
|
||||
// The cap evicts the oldest knock.
|
||||
// Flood from many DISTINCT source IPs (so the per-IP cap doesn't kick in) → the global cap
|
||||
// holds at PENDING_CAP, evicting the oldest non-parked entries first.
|
||||
for i in 0..(PENDING_CAP + 3) {
|
||||
np.note_pending("flood", &format!("f{i:03}"));
|
||||
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
|
||||
np.note_pending("flood", &format!("f{i:03}"), Some(ip));
|
||||
}
|
||||
let pend = np.pending();
|
||||
assert_eq!(pend.len(), PENDING_CAP);
|
||||
@@ -610,7 +735,7 @@ mod tests {
|
||||
let p = temp();
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||
np.note_pending("Knocker", "cc44");
|
||||
np.note_pending("Knocker", "cc44", None);
|
||||
assert_eq!(np.pending().len(), 1);
|
||||
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
|
||||
np.add("Knocker", "CC44").unwrap();
|
||||
@@ -656,7 +781,7 @@ mod tests {
|
||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||
|
||||
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
||||
np.note_pending("Knocker", "ab01");
|
||||
np.note_pending("Knocker", "ab01", None);
|
||||
let d = np
|
||||
.wait_for_decision("ab01", Duration::from_millis(80))
|
||||
.await;
|
||||
@@ -681,7 +806,7 @@ mod tests {
|
||||
assert!(np.is_paired("ab01"));
|
||||
|
||||
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
||||
np.note_pending("Knock2", "cd02");
|
||||
np.note_pending("Knock2", "cd02", None);
|
||||
let np3 = np.clone();
|
||||
let waiter =
|
||||
tokio::spawn(
|
||||
@@ -703,4 +828,62 @@ mod tests {
|
||||
assert_eq!(d, PairingDecision::Approved);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
|
||||
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
|
||||
#[test]
|
||||
fn armed_pin_is_fingerprint_bindable() {
|
||||
let p = temp();
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||
// Unbound: any fingerprint resolves to the PIN (legacy behavior).
|
||||
let pin = np.arm(Duration::from_secs(60));
|
||||
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin));
|
||||
assert!(matches!(np.pin_for_attempt("bb22"), PinAttempt::Pin(_)));
|
||||
// Bound to AA11: only that fp (case-insensitive) gets the PIN; another fp is BoundToOther —
|
||||
// the caller rejects it WITHOUT consuming the window.
|
||||
let pin = np.arm_for(Duration::from_secs(60), Some("AA11".into()));
|
||||
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin));
|
||||
assert!(matches!(
|
||||
np.pin_for_attempt("bb22"),
|
||||
PinAttempt::BoundToOther
|
||||
));
|
||||
np.disarm();
|
||||
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Disarmed));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// #13: one source IP can't exceed the per-IP cap, and a parked (held-open) genuine knock is
|
||||
/// never evicted by a flood — even one that fills the global cap from many distinct IPs.
|
||||
#[test]
|
||||
fn pending_per_ip_cap_and_parked_protection() {
|
||||
let p = temp();
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||
// Per-IP cap: one source flooding distinct fingerprints holds at most MAX_PENDING_PER_IP.
|
||||
let attacker = IpAddr::from([192, 168, 1, 66]);
|
||||
for i in 0..20 {
|
||||
np.note_pending("flood", &format!("atk{i:03}"), Some(attacker));
|
||||
}
|
||||
assert_eq!(
|
||||
np.pending().len(),
|
||||
MAX_PENDING_PER_IP,
|
||||
"one IP can't exceed the per-IP cap"
|
||||
);
|
||||
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood
|
||||
// from many distinct IPs that fills the global cap.
|
||||
let legit = IpAddr::from([192, 168, 1, 50]);
|
||||
np.note_pending("Living Room", "legit01", Some(legit));
|
||||
np.set_parked("legit01", true);
|
||||
for i in 0..(PENDING_CAP * 2) {
|
||||
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
|
||||
np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
|
||||
}
|
||||
assert!(
|
||||
np.pending_contains("legit01"),
|
||||
"a parked, held-open knock is never evicted by a flood"
|
||||
);
|
||||
assert!(np.pending().len() <= PENDING_CAP, "global cap still holds");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,10 +532,25 @@ async fn serve_session(
|
||||
.await
|
||||
.map_err(|_| anyhow!("first message timeout"))??;
|
||||
if let Ok(req) = PairRequest::decode(&first) {
|
||||
// Read the live arming PIN per attempt, so a window that lapsed no longer pairs.
|
||||
let pin = np
|
||||
.current_pin()
|
||||
.context("pairing not armed (arm it in the console, or start with --allow-pairing)")?;
|
||||
// The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
|
||||
// a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
|
||||
// not be consumable — or burnable — by any other fingerprint.
|
||||
let client_fp = endpoint::peer_fingerprint(&conn)
|
||||
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
|
||||
let client_fp_hex = fingerprint_hex(&client_fp);
|
||||
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
|
||||
// fingerprint binding.
|
||||
let pin = match np.pin_for_attempt(&client_fp_hex) {
|
||||
crate::native_pairing::PinAttempt::Pin(pin) => pin,
|
||||
crate::native_pairing::PinAttempt::Disarmed => anyhow::bail!(
|
||||
"pairing not armed (arm it in the console, or start with --allow-pairing)"
|
||||
),
|
||||
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
|
||||
// NOT consume (burn) the operator's window for the device they actually selected (#9).
|
||||
crate::native_pairing::PinAttempt::BoundToOther => anyhow::bail!(
|
||||
"pairing is armed for a different device — this attempt does not consume the window"
|
||||
),
|
||||
};
|
||||
{
|
||||
let mut last = last_pairing.lock().unwrap();
|
||||
if let Some(t) = *last {
|
||||
@@ -589,7 +604,9 @@ async fn serve_session(
|
||||
);
|
||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||
"unpaired device knocked — parking connection for delegated approval in the console");
|
||||
np.note_pending(&label, &fp_hex);
|
||||
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
|
||||
// host from flooding/evicting genuine knocks (#13).
|
||||
np.note_pending(&label, &fp_hex, Some(peer.ip()));
|
||||
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
||||
// permit (a handful of parked knocks would otherwise block every real session).
|
||||
drop(permit);
|
||||
@@ -1382,6 +1399,8 @@ enum PadBackend {
|
||||
DualSense(crate::inject::dualsense::DualSenseManager),
|
||||
#[cfg(target_os = "linux")]
|
||||
DualShock4(crate::inject::dualshock4::DualShock4Manager),
|
||||
#[cfg(target_os = "linux")]
|
||||
SteamDeck(crate::inject::steam_controller::SteamControllerManager),
|
||||
#[cfg(target_os = "windows")]
|
||||
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1403,6 +1422,12 @@ impl PadBackend {
|
||||
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
|
||||
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
|
||||
}
|
||||
GamepadPref::SteamDeck => {
|
||||
tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)");
|
||||
return PadBackend::SteamDeck(
|
||||
crate::inject::steam_controller::SteamControllerManager::new(),
|
||||
);
|
||||
}
|
||||
GamepadPref::XboxOne => {
|
||||
tracing::info!("gamepad backend: uinput X-Box One/Series pad");
|
||||
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
|
||||
@@ -1438,6 +1463,8 @@ impl PadBackend {
|
||||
PadBackend::DualSense(m) => m.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1454,6 +1481,8 @@ impl PadBackend {
|
||||
PadBackend::DualSense(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1479,6 +1508,8 @@ impl PadBackend {
|
||||
PadBackend::DualSense(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1498,6 +1529,8 @@ impl PadBackend {
|
||||
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1877,10 +1910,94 @@ 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
|
||||
// Windows (XInput can't tell them apart anyway).
|
||||
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
||||
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
|
||||
// so it folds to Xbox360 for now (Windows Steam devices are M7).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
|
||||
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
|
||||
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
|
||||
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
let needs_uhid = matches!(
|
||||
chosen,
|
||||
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
|
||||
);
|
||||
if needs_uhid
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/uhid")
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"/dev/uhid not writable — falling back to the X-Box 360 pad"
|
||||
);
|
||||
return GamepadPref::Xbox360;
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
|
||||
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
|
||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
||||
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_steam_controller_present() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
||||
return false;
|
||||
}
|
||||
match std::fs::read_link(e.path()) {
|
||||
Ok(target) => !target.to_string_lossy().contains("/virtual/"),
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to
|
||||
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
|
||||
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if !forced && physical_steam_controller_present() {
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
|
||||
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
|
||||
);
|
||||
return degrade_if_no_uhid(GamepadPref::DualSense);
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
@@ -1891,6 +2008,14 @@ fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
|
||||
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
|
||||
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
|
||||
let chosen = degrade_if_no_uhid(chosen);
|
||||
// Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical
|
||||
// Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on
|
||||
// a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides.
|
||||
let chosen = degrade_steam_on_conflict(chosen);
|
||||
match pref {
|
||||
GamepadPref::Auto => {
|
||||
// The operator's env knob deserves a diagnostic when it didn't drive the
|
||||
|
||||
@@ -327,17 +327,17 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Windows defaults a new IddCx monitor into CLONE mode when a physical display is already
|
||||
// active (a laptop panel, an attached monitor): the cloned IDD shares that display's source, so
|
||||
// the OS never commits a distinct path for it and capture sees no frames. Force EXTEND first so
|
||||
// the IDD comes up as its OWN active path; the resolve loop below then finds it. Idempotent /
|
||||
// no-op on a sole-display box, so it's safe on the headless single-GPU path too.
|
||||
// SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (a CCD topology apply) with no
|
||||
// borrowed caller memory; it runs under the manager `state` lock, the sole topology mutator.
|
||||
unsafe { force_extend_topology() };
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its
|
||||
// OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode`
|
||||
// below then promotes it to primary and `isolate_displays_ccd` makes it the sole composited
|
||||
// desktop — the proven flow). May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// the capture backend re-resolves once a GPU is present.
|
||||
//
|
||||
// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED
|
||||
// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed
|
||||
// the headless path — the IDD then never gets its own path → "not an active display path" → black).
|
||||
// force-EXTEND is only the FALLBACK below, for an integrated-screen box where a fresh IDD is CLONED
|
||||
// onto the panel (shares its source) instead of getting its own path.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
@@ -349,6 +349,32 @@ impl VirtualDisplayManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for an integrated-screen box (e.g. a laptop panel): Windows CLONES a freshly-added
|
||||
// IDD onto the existing display, sharing its source, so it never gets its own committed path. On
|
||||
// the IddCx clone behaviour observed live (commit 8e87e61, an Intel-iGPU + NVIDIA-Optimus laptop)
|
||||
// `resolve_gdi_name` then stays None — so this `is_none()` fallback fires, force-EXTENDs to
|
||||
// de-clone, and the second resolve finds the now-committed path. Headless/extended boxes already
|
||||
// resolved above (the IDD auto-activates with its OWN source) and skip this — which is the whole
|
||||
// point, since force-EXTEND's bare preset is ACCESS_DENIED from our service context there.
|
||||
//
|
||||
// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone
|
||||
// appear as a *shared-source ACTIVE* path (resolve → Some), which this `is_none()` gate would NOT
|
||||
// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
|
||||
// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
|
||||
if gdi_name.is_none() {
|
||||
// SAFETY: as above — `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no
|
||||
// borrowed caller memory, under the `state` lock.
|
||||
unsafe { force_extend_topology() };
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: as the resolve loop above.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Vendored + trimmed copy of the `usbip` crate (jiegec/usbip v0.8.0, MIT), reduced to the
|
||||
# USB/IP *server simulation* path only: we present a virtual Steam Deck and let the local
|
||||
# `vhci_hcd` attach it. The upstream crate hard-depends on `rusb`→`libusb1-sys` (for its USB
|
||||
# *host* mode, which we do not use and which would add a libusb runtime dep + break `musl`),
|
||||
# so the host modules (`host.rs`, the `rusb`/`nusb` device constructors) and the helper
|
||||
# interface handlers (`cdc.rs`/`hid.rs`) are removed. What remains — the device model, the
|
||||
# USB/IP protocol framing, and the `UsbInterfaceHandler` trait — is pure `std` + `tokio` and
|
||||
# carries no libusb dependency. See `NOTICE` for upstream attribution.
|
||||
[package]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "Trimmed usbip server-simulation core (no libusb) — vendored for the virtual Steam Deck"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "usbip_sim"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
num-derive = "0.4"
|
||||
num-traits = "0.2"
|
||||
# `time` is for the interrupt-IN pacing added in device.rs (punktfunk modification — see NOTICE).
|
||||
tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time"] }
|
||||
# Upstream gated its struct derives behind a `serde` feature; kept (off by default) so the
|
||||
# `#[cfg(feature = "serde")]` attributes stay valid and the vendored diff stays minimal.
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
serde = ["dep:serde"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2025 Jiajie Chen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
This crate (`usbip-sim`) is a vendored, trimmed copy of:
|
||||
|
||||
usbip v0.8.0
|
||||
Copyright (c) Jiajie Chen <c@jia.je> and contributors
|
||||
https://github.com/jiegec/usbip
|
||||
Licensed under the MIT License.
|
||||
|
||||
Modifications by the punktfunk project:
|
||||
- Removed the USB host modules (`src/host.rs`) and the `rusb`/`nusb` device
|
||||
constructors in `src/lib.rs` (`with_rusb_*`, `with_nusb_*`, `new_from_host*`),
|
||||
eliminating the libusb runtime dependency (which also broke `musl`).
|
||||
- Removed the example helper interface handlers `src/cdc.rs` and `src/hid.rs`.
|
||||
- Replaced the `rusb::Direction` re-export and `rusb::Version` conversions with
|
||||
local definitions.
|
||||
- Dropped the in-crate test modules (kept the library surface only).
|
||||
- Paced interrupt/bulk IN endpoint transfers by bInterval in `device.rs`
|
||||
`handle_urb` (so a simulated interrupt-IN mimics a real device's
|
||||
NAK-until-bInterval behaviour rather than free-running over the loopback
|
||||
link); added the tokio `time` feature for it.
|
||||
|
||||
Only the USB/IP server *simulation* path is retained: the device model, the
|
||||
USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT
|
||||
license text is reproduced in LICENSE-MIT.
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::*;
|
||||
|
||||
/// A list of known USB speeds
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum UsbSpeed {
|
||||
Unknown = 0x0,
|
||||
Low,
|
||||
Full,
|
||||
High,
|
||||
Wireless,
|
||||
Super,
|
||||
SuperPlus,
|
||||
}
|
||||
|
||||
/// A list of defined USB class codes
|
||||
// https://www.usb.org/defined-class-codes
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum ClassCode {
|
||||
SeeInterface = 0,
|
||||
Audio,
|
||||
CDC,
|
||||
HID,
|
||||
Physical = 0x05,
|
||||
Image,
|
||||
Printer,
|
||||
MassStorage,
|
||||
Hub,
|
||||
CDCData,
|
||||
SmartCard,
|
||||
ContentSecurity = 0x0D,
|
||||
Video,
|
||||
PersonalHealthcare,
|
||||
AudioVideo,
|
||||
Billboard,
|
||||
TypeCBridge,
|
||||
Diagnostic = 0xDC,
|
||||
WirelessController = 0xE0,
|
||||
Misc = 0xEF,
|
||||
ApplicationSpecific = 0xFE,
|
||||
VendorSpecific = 0xFF,
|
||||
}
|
||||
|
||||
/// A list of defined USB endpoint attributes
|
||||
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum EndpointAttributes {
|
||||
Control = 0,
|
||||
Isochronous,
|
||||
Bulk,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
/// USB endpoint direction: IN or OUT.
|
||||
///
|
||||
/// Upstream re-exported `rusb::Direction`; vendored locally so this crate carries no libusb
|
||||
/// dependency. `UsbEndpoint::direction()` returns this, and `device.rs` matches on the variants.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum Direction {
|
||||
/// Host → device (`bEndpointAddress` bit 7 clear).
|
||||
Out,
|
||||
/// Device → host (`bEndpointAddress` bit 7 set).
|
||||
In,
|
||||
}
|
||||
|
||||
/// Emulated max packet size of EP0
|
||||
pub const EP0_MAX_PACKET_SIZE: u16 = 64;
|
||||
|
||||
/// A list of defined USB standard requests
|
||||
/// from USB 2.0 standard Table 9.4. Standard Request Codes
|
||||
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum StandardRequest {
|
||||
GetStatus = 0,
|
||||
ClearFeature = 1,
|
||||
SetFeature = 3,
|
||||
SetAddress = 5,
|
||||
GetDescriptor = 6,
|
||||
SetDescriptor = 7,
|
||||
GetConfiguration = 8,
|
||||
SetConfiguration = 9,
|
||||
GetInterface = 10,
|
||||
SetInterface = 11,
|
||||
SynchFrame = 12,
|
||||
}
|
||||
|
||||
/// A list of defined USB descriptor types
|
||||
/// from USB 2.0 standard Table 9.5. Descriptor Types
|
||||
#[derive(Copy, Clone, Debug, FromPrimitive)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum DescriptorType {
|
||||
/// DEVICE
|
||||
Device = 1,
|
||||
/// CONFIGURATION
|
||||
Configuration = 2,
|
||||
/// STRING
|
||||
String = 3,
|
||||
/// INTERFACE
|
||||
Interface = 4,
|
||||
/// ENDPOINT
|
||||
Endpoint = 5,
|
||||
/// DEVICE_QUALIFIER
|
||||
DeviceQualifier = 6,
|
||||
/// OTHER_SPEED_CONFIGURATION
|
||||
OtherSpeedConfiguration = 7,
|
||||
/// INTERFACE_POINTER
|
||||
InterfacePower = 8,
|
||||
/// OTG
|
||||
OTG = 9,
|
||||
/// DEBUG
|
||||
Debug = 0xA,
|
||||
/// INTERFACE_ASSOCIATION
|
||||
InterfaceAssociation = 0xB,
|
||||
/// BOS
|
||||
BOS = 0xF,
|
||||
// DEVICE CAPABILITY
|
||||
DeviceCapability = 0x10,
|
||||
/// SUPERSPEED_USB_ENDPOINT_COMPANION
|
||||
SuperspeedUsbEndpointCompanion = 0x30,
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct Version {
|
||||
pub major: u8,
|
||||
pub minor: u8,
|
||||
pub patch: u8,
|
||||
}
|
||||
|
||||
// (Upstream's `From<rusb::Version>` conversions removed — this crate has no libusb dependency.)
|
||||
|
||||
/// bcdDevice
|
||||
impl From<u16> for Version {
|
||||
fn from(value: u16) -> Self {
|
||||
Self {
|
||||
major: (value >> 8) as u8,
|
||||
minor: ((value >> 4) & 0xF) as u8,
|
||||
patch: (value & 0xF) as u8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represent a USB device
|
||||
#[derive(Clone, Default, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||
pub struct UsbDevice {
|
||||
pub path: String,
|
||||
pub bus_id: String,
|
||||
pub bus_num: u32,
|
||||
pub dev_num: u32,
|
||||
pub speed: u32,
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
pub device_bcd: Version,
|
||||
pub device_class: u8,
|
||||
pub device_subclass: u8,
|
||||
pub device_protocol: u8,
|
||||
pub configuration_value: u8,
|
||||
pub num_configurations: u8,
|
||||
pub interfaces: Vec<UsbInterface>,
|
||||
|
||||
#[cfg_attr(feature = "serde", serde(skip))]
|
||||
pub device_handler: Option<Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>>,
|
||||
|
||||
pub usb_version: Version,
|
||||
|
||||
pub(crate) ep0_in: UsbEndpoint,
|
||||
pub(crate) ep0_out: UsbEndpoint,
|
||||
// strings
|
||||
pub(crate) string_pool: HashMap<u8, String>,
|
||||
pub(crate) string_configuration: u8,
|
||||
pub(crate) string_manufacturer: u8,
|
||||
pub(crate) string_product: u8,
|
||||
pub(crate) string_serial: u8,
|
||||
}
|
||||
|
||||
impl UsbDevice {
|
||||
pub fn new(index: u32) -> Self {
|
||||
let mut res = Self {
|
||||
path: "/sys/bus/0/0/0".to_string(),
|
||||
bus_id: "0-0-0".to_string(),
|
||||
dev_num: index,
|
||||
speed: UsbSpeed::High as u32,
|
||||
ep0_in: UsbEndpoint {
|
||||
address: 0x80,
|
||||
attributes: EndpointAttributes::Control as u8,
|
||||
max_packet_size: EP0_MAX_PACKET_SIZE,
|
||||
interval: 0,
|
||||
},
|
||||
ep0_out: UsbEndpoint {
|
||||
address: 0x00,
|
||||
attributes: EndpointAttributes::Control as u8,
|
||||
max_packet_size: EP0_MAX_PACKET_SIZE,
|
||||
interval: 0,
|
||||
},
|
||||
// configured by default
|
||||
configuration_value: 1,
|
||||
num_configurations: 1,
|
||||
..Self::default()
|
||||
};
|
||||
res.string_configuration = res.new_string("Default Configuration");
|
||||
res.string_manufacturer = res.new_string("Manufacturer");
|
||||
res.string_product = res.new_string("Product");
|
||||
res.string_serial = res.new_string("Serial");
|
||||
res
|
||||
}
|
||||
|
||||
/// Returns the old value, if present.
|
||||
pub fn set_configuration_name(&mut self, name: &str) -> Option<String> {
|
||||
let old = (self.string_configuration != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_configuration))
|
||||
.flatten();
|
||||
self.string_configuration = self.new_string(name);
|
||||
old
|
||||
}
|
||||
|
||||
/// Unset configuration name and returns the old value, if present.
|
||||
pub fn unset_configuration_name(&mut self) -> Option<String> {
|
||||
let old = (self.string_configuration != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_configuration))
|
||||
.flatten();
|
||||
self.string_configuration = 0;
|
||||
old
|
||||
}
|
||||
|
||||
/// Returns the old value, if present.
|
||||
pub fn set_serial_number(&mut self, name: &str) -> Option<String> {
|
||||
let old = (self.string_serial != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_serial))
|
||||
.flatten();
|
||||
self.string_serial = self.new_string(name);
|
||||
old
|
||||
}
|
||||
|
||||
/// Unset serial number and returns the old value, if present.
|
||||
pub fn unset_serial_number(&mut self) -> Option<String> {
|
||||
let old = (self.string_serial != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_serial))
|
||||
.flatten();
|
||||
self.string_serial = 0;
|
||||
old
|
||||
}
|
||||
|
||||
/// Returns the old value, if present.
|
||||
pub fn set_product_name(&mut self, name: &str) -> Option<String> {
|
||||
let old = (self.string_product != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_product))
|
||||
.flatten();
|
||||
self.string_product = self.new_string(name);
|
||||
old
|
||||
}
|
||||
|
||||
/// Unset product name and returns the old value, if present.
|
||||
pub fn unset_product_name(&mut self) -> Option<String> {
|
||||
let old = (self.string_product != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_product))
|
||||
.flatten();
|
||||
self.string_product = 0;
|
||||
old
|
||||
}
|
||||
|
||||
/// Returns the old value, if present.
|
||||
pub fn set_manufacturer_name(&mut self, name: &str) -> Option<String> {
|
||||
let old = (self.string_manufacturer != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_manufacturer))
|
||||
.flatten();
|
||||
self.string_manufacturer = self.new_string(name);
|
||||
old
|
||||
}
|
||||
|
||||
/// Unset manufacturer name and returns the old value, if present.
|
||||
pub fn unset_manufacturer_name(&mut self) -> Option<String> {
|
||||
let old = (self.string_manufacturer != 0)
|
||||
.then(|| self.string_pool.remove(&self.string_manufacturer))
|
||||
.flatten();
|
||||
self.string_manufacturer = 0;
|
||||
old
|
||||
}
|
||||
|
||||
pub fn with_interface(
|
||||
mut self,
|
||||
interface_class: u8,
|
||||
interface_subclass: u8,
|
||||
interface_protocol: u8,
|
||||
name: Option<&str>,
|
||||
endpoints: Vec<UsbEndpoint>,
|
||||
handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||
) -> Self {
|
||||
let string_interface = name.map(|name| self.new_string(name)).unwrap_or(0);
|
||||
let class_specific_descriptor = handler.lock().unwrap().get_class_specific_descriptor();
|
||||
self.interfaces.push(UsbInterface {
|
||||
interface_class,
|
||||
interface_subclass,
|
||||
interface_protocol,
|
||||
endpoints,
|
||||
string_interface,
|
||||
class_specific_descriptor,
|
||||
handler,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_device_handler(
|
||||
mut self,
|
||||
handler: Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>,
|
||||
) -> Self {
|
||||
self.device_handler = Some(handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn new_string(&mut self, s: &str) -> u8 {
|
||||
for i in 1.. {
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.string_pool.entry(i) {
|
||||
e.insert(s.to_string());
|
||||
return i;
|
||||
}
|
||||
}
|
||||
panic!("string poll exhausted")
|
||||
}
|
||||
|
||||
pub(crate) fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> {
|
||||
if ep == self.ep0_in.address {
|
||||
Some((self.ep0_in, None))
|
||||
} else if ep == self.ep0_out.address {
|
||||
Some((self.ep0_out, None))
|
||||
} else {
|
||||
for intf in &self.interfaces {
|
||||
for endpoint in &intf.endpoints {
|
||||
if endpoint.address == ep {
|
||||
return Some((*endpoint, Some(intf)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(312);
|
||||
|
||||
let mut path = self.path.as_bytes().to_vec();
|
||||
debug_assert!(path.len() <= 256);
|
||||
path.resize(256, 0);
|
||||
result.extend_from_slice(path.as_slice());
|
||||
|
||||
let mut bus_id = self.bus_id.as_bytes().to_vec();
|
||||
debug_assert!(bus_id.len() <= 32);
|
||||
bus_id.resize(32, 0);
|
||||
result.extend_from_slice(bus_id.as_slice());
|
||||
|
||||
result.extend_from_slice(&self.bus_num.to_be_bytes());
|
||||
result.extend_from_slice(&self.dev_num.to_be_bytes());
|
||||
result.extend_from_slice(&self.speed.to_be_bytes());
|
||||
result.extend_from_slice(&self.vendor_id.to_be_bytes());
|
||||
result.extend_from_slice(&self.product_id.to_be_bytes());
|
||||
result.push(self.device_bcd.major);
|
||||
result.push(self.device_bcd.minor);
|
||||
result.push(self.device_class);
|
||||
result.push(self.device_subclass);
|
||||
result.push(self.device_protocol);
|
||||
result.push(self.configuration_value);
|
||||
result.push(self.num_configurations);
|
||||
result.push(self.interfaces.len() as u8);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn to_bytes_with_interfaces(&self) -> Vec<u8> {
|
||||
let mut result = self.to_bytes();
|
||||
result.reserve(4 * self.interfaces.len());
|
||||
|
||||
for intf in &self.interfaces {
|
||||
result.push(intf.interface_class);
|
||||
result.push(intf.interface_subclass);
|
||||
result.push(intf.interface_protocol);
|
||||
result.push(0); // padding
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_urb(
|
||||
&self,
|
||||
ep: UsbEndpoint,
|
||||
intf: Option<&UsbInterface>,
|
||||
transfer_buffer_length: u32,
|
||||
setup_packet: SetupPacket,
|
||||
out_data: &[u8],
|
||||
) -> Result<Vec<u8>> {
|
||||
use DescriptorType::*;
|
||||
use Direction::*;
|
||||
use EndpointAttributes::*;
|
||||
use StandardRequest::*;
|
||||
|
||||
match (FromPrimitive::from_u8(ep.attributes), ep.direction()) {
|
||||
(Some(Control), In) => {
|
||||
// control in
|
||||
debug!("Control IN setup={setup_packet:x?}");
|
||||
match (
|
||||
setup_packet.request_type,
|
||||
FromPrimitive::from_u8(setup_packet.request),
|
||||
) {
|
||||
(0b10000000, Some(GetDescriptor)) => {
|
||||
// high byte: type
|
||||
match FromPrimitive::from_u16(setup_packet.value >> 8) {
|
||||
Some(Device) => {
|
||||
debug!("Get device descriptor");
|
||||
// Standard Device Descriptor
|
||||
let mut desc = vec![
|
||||
0x12, // bLength
|
||||
Device as u8, // bDescriptorType: Device
|
||||
self.usb_version.minor,
|
||||
self.usb_version.major, // bcdUSB: USB 2.0
|
||||
self.device_class, // bDeviceClass
|
||||
self.device_subclass, // bDeviceSubClass
|
||||
self.device_protocol, // bDeviceProtocol
|
||||
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
|
||||
self.vendor_id as u8, // idVendor
|
||||
(self.vendor_id >> 8) as u8,
|
||||
self.product_id as u8, // idProduct
|
||||
(self.product_id >> 8) as u8,
|
||||
self.device_bcd.minor, // bcdDevice
|
||||
self.device_bcd.major,
|
||||
self.string_manufacturer, // iManufacturer
|
||||
self.string_product, // iProduct
|
||||
self.string_serial, // iSerial
|
||||
self.num_configurations, // bNumConfigurations
|
||||
];
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
}
|
||||
Some(BOS) => {
|
||||
debug!("Get BOS descriptor");
|
||||
let mut desc = vec![
|
||||
0x05, // bLength
|
||||
BOS as u8, // bDescriptorType: BOS
|
||||
0x05, 0x00, // wTotalLength
|
||||
0x00, // bNumCapabilities
|
||||
];
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
}
|
||||
Some(Configuration) => {
|
||||
debug!("Get configuration descriptor");
|
||||
// Standard Configuration Descriptor
|
||||
let mut desc = vec![
|
||||
0x09, // bLength
|
||||
Configuration as u8, // bDescriptorType: Configuration
|
||||
0x00,
|
||||
0x00, // wTotalLength: to be filled below
|
||||
self.interfaces.len() as u8, // bNumInterfaces
|
||||
self.configuration_value, // bConfigurationValue
|
||||
self.string_configuration, // iConfiguration
|
||||
0x80, // bmAttributes: Bus Powered
|
||||
0x32, // bMaxPower: 100mA
|
||||
];
|
||||
for (i, intf) in self.interfaces.iter().enumerate() {
|
||||
let mut intf_desc = vec![
|
||||
0x09, // bLength
|
||||
Interface as u8, // bDescriptorType: Interface
|
||||
i as u8, // bInterfaceNum
|
||||
0x00, // bAlternateSettings
|
||||
intf.endpoints.len() as u8, // bNumEndpoints
|
||||
intf.interface_class, // bInterfaceClass
|
||||
intf.interface_subclass, // bInterfaceSubClass
|
||||
intf.interface_protocol, // bInterfaceProtocol
|
||||
intf.string_interface, //iInterface
|
||||
];
|
||||
// class specific endpoint
|
||||
let mut specific = intf.class_specific_descriptor.clone();
|
||||
intf_desc.append(&mut specific);
|
||||
// endpoint descriptors
|
||||
for endpoint in &intf.endpoints {
|
||||
let mut ep_desc = vec![
|
||||
0x07, // bLength
|
||||
Endpoint as u8, // bDescriptorType: Endpoint
|
||||
endpoint.address, // bEndpointAddress
|
||||
endpoint.attributes, // bmAttributes
|
||||
endpoint.max_packet_size as u8,
|
||||
(endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize
|
||||
endpoint.interval, // bInterval
|
||||
];
|
||||
intf_desc.append(&mut ep_desc);
|
||||
}
|
||||
desc.append(&mut intf_desc);
|
||||
}
|
||||
// length
|
||||
let len = desc.len() as u16;
|
||||
desc[2] = len as u8;
|
||||
desc[3] = (len >> 8) as u8;
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
}
|
||||
Some(String) => {
|
||||
debug!("Get string descriptor");
|
||||
let index = setup_packet.value as u8;
|
||||
if index == 0 {
|
||||
// String Descriptor Zero, Specifying Languages Supported by the Device
|
||||
// language ids
|
||||
let mut desc = vec![
|
||||
4, // bLength
|
||||
DescriptorType::String as u8, // bDescriptorType
|
||||
0x09,
|
||||
0x04, // wLANGID[0], en-US
|
||||
];
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
} else if let Some(s) = &self.string_pool.get(&index) {
|
||||
// UNICODE String Descriptor
|
||||
let bytes: Vec<u16> = s.encode_utf16().collect();
|
||||
let mut desc = vec![
|
||||
2 + bytes.len() as u8 * 2, // bLength
|
||||
DescriptorType::String as u8, // bDescriptorType
|
||||
];
|
||||
for byte in bytes {
|
||||
desc.push(byte as u8);
|
||||
desc.push((byte >> 8) as u8);
|
||||
}
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("Invalid string index: {index}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(DeviceQualifier) => {
|
||||
debug!("Get device qualifier descriptor");
|
||||
// Device_Qualifier Descriptor
|
||||
let mut desc = vec![
|
||||
0x0A, // bLength
|
||||
DeviceQualifier as u8, // bDescriptorType: Device Qualifier
|
||||
self.usb_version.minor,
|
||||
self.usb_version.major, // bcdUSB
|
||||
self.device_class, // bDeviceClass
|
||||
self.device_subclass, // bDeviceSUbClass
|
||||
self.device_protocol, // bDeviceProtocol
|
||||
self.ep0_in.max_packet_size as u8, // bMaxPacketSize0
|
||||
self.num_configurations, // bNumConfigurations
|
||||
0x00, // bReserved
|
||||
];
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
}
|
||||
_ => {
|
||||
warn!("unknown desc type: {setup_packet:x?}");
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if setup_packet.request_type & 0xF == 1 => {
|
||||
// to interface
|
||||
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||
// only low 8 bits are valid
|
||||
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
|
||||
let mut handler = intf.handler.lock().unwrap();
|
||||
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||
}
|
||||
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
|
||||
// to device
|
||||
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||
let lock = self.device_handler.as_ref().unwrap();
|
||||
let mut handler = lock.lock().unwrap();
|
||||
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
|
||||
}
|
||||
_ => unimplemented!("control in"),
|
||||
}
|
||||
}
|
||||
(Some(Control), Out) => {
|
||||
// control out
|
||||
debug!("Control OUT setup={setup_packet:x?}");
|
||||
match (
|
||||
setup_packet.request_type,
|
||||
FromPrimitive::from_u8(setup_packet.request),
|
||||
) {
|
||||
(0b00000000, Some(SetConfiguration)) => {
|
||||
let mut desc = vec![
|
||||
self.configuration_value, // bConfigurationValue
|
||||
];
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
desc.resize(setup_packet.length as usize, 0);
|
||||
}
|
||||
Ok(desc)
|
||||
}
|
||||
_ if setup_packet.request_type & 0xF == 1 => {
|
||||
// to interface
|
||||
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||
// only low 8 bits are valid
|
||||
let intf = &self.interfaces[setup_packet.index as usize & 0xFF];
|
||||
let mut handler = intf.handler.lock().unwrap();
|
||||
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||
}
|
||||
_ if setup_packet.request_type & 0xF == 0 && self.device_handler.is_some() => {
|
||||
// to device
|
||||
// see https://www.beyondlogic.org/usbnutshell/usb6.shtml
|
||||
let lock = self.device_handler.as_ref().unwrap();
|
||||
let mut handler = lock.lock().unwrap();
|
||||
handler.handle_urb(transfer_buffer_length, setup_packet, out_data)
|
||||
}
|
||||
_ => unimplemented!("control out"),
|
||||
}
|
||||
}
|
||||
(Some(_), _) => {
|
||||
// others (interrupt / bulk / iso transfers to an endpoint)
|
||||
// punktfunk modification: pace IN transfers by bInterval so a virtual interrupt-IN
|
||||
// endpoint mimics a real device's NAK-until-bInterval behaviour instead of
|
||||
// free-running as fast as the transport allows (vhci_hcd does not throttle the
|
||||
// server side, so an unpaced sim would spin the loopback link). HS bInterval N →
|
||||
// 2^(N-1) microframes × 125µs.
|
||||
if let In = ep.direction() {
|
||||
let n = ep.interval.clamp(1, 16) as u32;
|
||||
let period_us = (1u32 << (n - 1)) * 125;
|
||||
tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await;
|
||||
}
|
||||
let intf = intf.unwrap();
|
||||
let mut handler = intf.handler.lock().unwrap();
|
||||
handler.handle_urb(intf, ep, transfer_buffer_length, setup_packet, out_data)
|
||||
}
|
||||
_ => unimplemented!("transfer to {:?}", ep),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handler for URB targeting the device
|
||||
pub trait UsbDeviceHandler: std::fmt::Debug {
|
||||
/// Handle a URB(USB Request Block) targeting at this device
|
||||
///
|
||||
/// When the lower 4 bits of `bmRequestType` is zero and the URB is not handled by the library, this function is called.
|
||||
/// The resulting data should not exceed `transfer_buffer_length`
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
transfer_buffer_length: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> Result<Vec<u8>>;
|
||||
|
||||
/// Helper to downcast to actual struct
|
||||
///
|
||||
/// Please implement it as:
|
||||
/// ```ignore
|
||||
/// fn as_any(&mut self) -> &mut dyn Any {
|
||||
/// self
|
||||
/// }
|
||||
/// ```
|
||||
fn as_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
/// Represent a USB endpoint
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct UsbEndpoint {
|
||||
/// bEndpointAddress
|
||||
pub address: u8,
|
||||
/// bmAttributes
|
||||
pub attributes: u8,
|
||||
/// wMaxPacketSize
|
||||
pub max_packet_size: u16,
|
||||
/// bInterval
|
||||
pub interval: u8,
|
||||
}
|
||||
|
||||
impl UsbEndpoint {
|
||||
/// Get direction from MSB of address
|
||||
pub fn direction(&self) -> Direction {
|
||||
if self.address & 0x80 != 0 {
|
||||
Direction::In
|
||||
} else {
|
||||
Direction::Out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is endpoint zero
|
||||
pub fn is_ep0(&self) -> bool {
|
||||
self.address & 0x7F == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use super::*;
|
||||
|
||||
/// Represent a USB interface
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||
pub struct UsbInterface {
|
||||
pub interface_class: u8,
|
||||
pub interface_subclass: u8,
|
||||
pub interface_protocol: u8,
|
||||
pub endpoints: Vec<UsbEndpoint>,
|
||||
pub string_interface: u8,
|
||||
pub class_specific_descriptor: Vec<u8>,
|
||||
|
||||
#[cfg_attr(feature = "serde", serde(skip))]
|
||||
pub handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||
}
|
||||
|
||||
/// A handler of a custom usb interface
|
||||
pub trait UsbInterfaceHandler: std::fmt::Debug {
|
||||
/// Return the class specific descriptor which is inserted between interface descriptor and endpoint descriptor
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8>;
|
||||
|
||||
/// Handle a URB(USB Request Block) targeting at this interface
|
||||
///
|
||||
/// Can be one of: control transfer to ep0 or other types of transfer to its endpoint.
|
||||
/// The resulting data should not exceed `transfer_buffer_length`.
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
transfer_buffer_length: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> Result<Vec<u8>>;
|
||||
|
||||
/// Helper to downcast to actual struct
|
||||
///
|
||||
/// Please implement it as:
|
||||
/// ```ignore
|
||||
/// fn as_any(&mut self) -> &mut dyn Any {
|
||||
/// self
|
||||
/// }
|
||||
/// ```
|
||||
fn as_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
//! A USB/IP server (simulation path only).
|
||||
//!
|
||||
//! Vendored + trimmed from `usbip` v0.8.0 (jiegec/usbip, MIT); the USB *host* modules and the
|
||||
//! `rusb`/`nusb` device constructors are removed so this carries no libusb dependency. See `NOTICE`.
|
||||
|
||||
use log::*;
|
||||
use num_derive::FromPrimitive;
|
||||
use num_traits::FromPrimitive;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{ErrorKind, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
use usbip_protocol::UsbIpCommand;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod consts;
|
||||
mod device;
|
||||
mod endpoint;
|
||||
mod interface;
|
||||
mod setup;
|
||||
pub mod usbip_protocol;
|
||||
mod util;
|
||||
pub use consts::*;
|
||||
pub use device::*;
|
||||
pub use endpoint::*;
|
||||
pub use interface::*;
|
||||
pub use setup::*;
|
||||
pub use util::*;
|
||||
|
||||
use crate::usbip_protocol::{UsbIpResponse, USBIP_RET_SUBMIT, USBIP_RET_UNLINK};
|
||||
|
||||
/// Main struct of a USB/IP server
|
||||
#[derive(Default, Debug)]
|
||||
pub struct UsbIpServer {
|
||||
available_devices: RwLock<Vec<UsbDevice>>,
|
||||
used_devices: RwLock<HashMap<String, UsbDevice>>,
|
||||
}
|
||||
|
||||
impl UsbIpServer {
|
||||
/// Create a [UsbIpServer] with simulated devices
|
||||
pub fn new_simulated(devices: Vec<UsbDevice>) -> Self {
|
||||
Self {
|
||||
available_devices: RwLock::new(devices),
|
||||
used_devices: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_device(&self, device: UsbDevice) {
|
||||
self.available_devices.write().await.push(device);
|
||||
}
|
||||
|
||||
pub async fn remove_device(&self, bus_id: &str) -> Result<()> {
|
||||
let mut available_devices = self.available_devices.write().await;
|
||||
|
||||
if let Some(device) = available_devices.iter().position(|d| d.bus_id == bus_id) {
|
||||
available_devices.remove(device);
|
||||
Ok(())
|
||||
} else if let Some(device) = self
|
||||
.used_devices
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.find(|d| d.bus_id == bus_id)
|
||||
{
|
||||
Err(std::io::Error::other(format!(
|
||||
"Device {} is in use",
|
||||
device.bus_id
|
||||
)))
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
format!("Device {bus_id} not found"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
mut socket: &mut T,
|
||||
server: Arc<UsbIpServer>,
|
||||
) -> Result<()> {
|
||||
let mut current_import_device_id: Option<String> = None;
|
||||
loop {
|
||||
let command = UsbIpCommand::read_from_socket(&mut socket).await;
|
||||
if let Err(err) = command {
|
||||
if let Some(dev_id) = current_import_device_id {
|
||||
let mut used_devices = server.used_devices.write().await;
|
||||
let mut available_devices = server.available_devices.write().await;
|
||||
match used_devices.remove(&dev_id) {
|
||||
Some(dev) => available_devices.push(dev),
|
||||
None => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if err.kind() == ErrorKind::UnexpectedEof {
|
||||
info!("Remote closed the connection");
|
||||
return Ok(());
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
let used_devices = server.used_devices.read().await;
|
||||
let mut current_import_device = current_import_device_id
|
||||
.clone()
|
||||
.and_then(|ref id| used_devices.get(id));
|
||||
|
||||
match command.unwrap() {
|
||||
UsbIpCommand::OpReqDevlist { .. } => {
|
||||
trace!("Got OP_REQ_DEVLIST");
|
||||
let devices = server.available_devices.read().await;
|
||||
|
||||
// OP_REP_DEVLIST
|
||||
UsbIpResponse::op_rep_devlist(&devices)
|
||||
.write_to_socket(socket)
|
||||
.await?;
|
||||
trace!("Sent OP_REP_DEVLIST");
|
||||
}
|
||||
UsbIpCommand::OpReqImport { busid, .. } => {
|
||||
trace!("Got OP_REQ_IMPORT");
|
||||
|
||||
current_import_device_id = None;
|
||||
current_import_device = None;
|
||||
std::mem::drop(used_devices);
|
||||
|
||||
let mut used_devices = server.used_devices.write().await;
|
||||
let mut available_devices = server.available_devices.write().await;
|
||||
let busid_compare =
|
||||
&busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())];
|
||||
for (i, dev) in available_devices.iter().enumerate() {
|
||||
if busid_compare == dev.bus_id.as_bytes() {
|
||||
let dev = available_devices.remove(i);
|
||||
let dev_id = dev.bus_id.clone();
|
||||
used_devices.insert(dev.bus_id.clone(), dev);
|
||||
current_import_device_id = dev_id.clone().into();
|
||||
current_import_device = Some(used_devices.get(&dev_id).unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let res = if let Some(dev) = current_import_device {
|
||||
UsbIpResponse::op_rep_import_success(dev)
|
||||
} else {
|
||||
UsbIpResponse::op_rep_import_fail()
|
||||
};
|
||||
res.write_to_socket(socket).await?;
|
||||
trace!("Sent OP_REP_IMPORT");
|
||||
}
|
||||
UsbIpCommand::UsbIpCmdSubmit {
|
||||
mut header,
|
||||
transfer_buffer_length,
|
||||
setup,
|
||||
data,
|
||||
..
|
||||
} => {
|
||||
trace!("Got USBIP_CMD_SUBMIT");
|
||||
let device = current_import_device.unwrap();
|
||||
|
||||
let out = header.direction == 0;
|
||||
let real_ep = if out { header.ep } else { header.ep | 0x80 };
|
||||
|
||||
header.command = USBIP_RET_SUBMIT.into();
|
||||
|
||||
let res = match device.find_ep(real_ep as u8) {
|
||||
None => {
|
||||
warn!("Endpoint {real_ep:02x?} not found");
|
||||
UsbIpResponse::usbip_ret_submit_fail(&header)
|
||||
}
|
||||
Some((ep, intf)) => {
|
||||
trace!("->Endpoint {ep:02x?}");
|
||||
trace!("->Setup {setup:02x?}");
|
||||
trace!("->Request {data:02x?}");
|
||||
let resp = device
|
||||
.handle_urb(
|
||||
ep,
|
||||
intf,
|
||||
transfer_buffer_length,
|
||||
SetupPacket::parse(&setup),
|
||||
&data,
|
||||
)
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(resp) => {
|
||||
if out {
|
||||
trace!("<-Wrote {}", data.len());
|
||||
} else {
|
||||
trace!("<-Resp {resp:02x?}");
|
||||
}
|
||||
UsbIpResponse::usbip_ret_submit_success(&header, 0, 0, resp, vec![])
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Error handling URB: {err}");
|
||||
UsbIpResponse::usbip_ret_submit_fail(&header)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
res.write_to_socket(socket).await?;
|
||||
trace!("Sent USBIP_RET_SUBMIT");
|
||||
}
|
||||
UsbIpCommand::UsbIpCmdUnlink {
|
||||
mut header,
|
||||
unlink_seqnum,
|
||||
} => {
|
||||
trace!("Got USBIP_CMD_UNLINK for {unlink_seqnum:10x?}");
|
||||
|
||||
header.command = USBIP_RET_UNLINK.into();
|
||||
|
||||
let res = UsbIpResponse::usbip_ret_unlink_success(&header);
|
||||
res.write_to_socket(socket).await?;
|
||||
trace!("Sent USBIP_RET_UNLINK");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a USB/IP server at `addr` using [TcpListener]
|
||||
pub async fn server(addr: SocketAddr, server: Arc<UsbIpServer>) {
|
||||
let listener = TcpListener::bind(addr).await.expect("bind to addr");
|
||||
|
||||
let server = async move {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((mut socket, _addr)) => {
|
||||
info!("Got connection from {:?}", socket.peer_addr());
|
||||
let new_server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = handler(&mut socket, new_server).await;
|
||||
info!("Handler ended with {res:?}");
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Got error {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
server.await
|
||||
}
|
||||
|
||||
// (Host-mode constructors and in-crate tests removed in the vendored copy — see NOTICE.)
|
||||
@@ -0,0 +1,31 @@
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Parse the SETUP packet of control transfers
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct SetupPacket {
|
||||
/// bmRequestType
|
||||
pub request_type: u8,
|
||||
/// bRequest
|
||||
pub request: u8,
|
||||
/// wValue
|
||||
pub value: u16,
|
||||
/// wIndex
|
||||
pub index: u16,
|
||||
/// wLength
|
||||
pub length: u16,
|
||||
}
|
||||
|
||||
impl SetupPacket {
|
||||
/// Parse a [SetupPacket] from raw setup packet
|
||||
pub fn parse(setup: &[u8; 8]) -> SetupPacket {
|
||||
SetupPacket {
|
||||
request_type: setup[0],
|
||||
request: setup[1],
|
||||
value: ((setup[3] as u16) << 8) | (setup[2] as u16),
|
||||
index: ((setup[5] as u16) << 8) | (setup[4] as u16),
|
||||
length: ((setup[7] as u16) << 8) | (setup[6] as u16),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! USB/IP protocol structs
|
||||
//!
|
||||
//! This module contains declarations of all structs used in the USB/IP protocol,
|
||||
//! as well as functions to serialize and deserialize them to/from byte arrays,
|
||||
//! and functions to send and receive them over a socket.
|
||||
//!
|
||||
//! They are based on the [Linux kernel documentation](https://docs.kernel.org/usb/usbip_protocol.html).
|
||||
|
||||
use log::trace;
|
||||
use std::io::Result;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::UsbDevice;
|
||||
|
||||
/// USB/IP protocol version
|
||||
///
|
||||
/// This is currently the only supported version of USB/IP
|
||||
/// for this library.
|
||||
pub const USBIP_VERSION: u16 = 0x0111;
|
||||
|
||||
/// Command code: Retrieve the list of exported USB devices
|
||||
pub const OP_REQ_DEVLIST: u16 = 0x8005;
|
||||
/// Command code: import a remote USB device
|
||||
pub const OP_REQ_IMPORT: u16 = 0x8003;
|
||||
/// Reply code: The list of exported USB devices
|
||||
pub const OP_REP_DEVLIST: u16 = 0x0005;
|
||||
/// Reply code: Reply to import
|
||||
pub const OP_REP_IMPORT: u16 = 0x0003;
|
||||
|
||||
/// Command code: Submit an URB
|
||||
pub const USBIP_CMD_SUBMIT: u16 = 0x0001;
|
||||
/// Command code: Unlink an URB
|
||||
pub const USBIP_CMD_UNLINK: u16 = 0x0002;
|
||||
/// Reply code: Reply for submitting an URB
|
||||
pub const USBIP_RET_SUBMIT: u16 = 0x0003;
|
||||
/// Reply code: Reply for URB unlink
|
||||
pub const USBIP_RET_UNLINK: u16 = 0x0004;
|
||||
|
||||
/// USB/IP direction
|
||||
///
|
||||
/// NOTE: Must not be confused with rusb::Direction,
|
||||
/// which has the opposite enum values. This is only for
|
||||
/// internal use in the USB/IP protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Direction {
|
||||
Out = 0,
|
||||
In = 1,
|
||||
}
|
||||
|
||||
/// Common header for all context sensitive packets
|
||||
///
|
||||
/// All commands/responses which rely on a device being attached
|
||||
/// to a client use this header.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct UsbIpHeaderBasic {
|
||||
pub command: u32,
|
||||
pub seqnum: u32,
|
||||
pub devid: u32,
|
||||
pub direction: u32,
|
||||
pub ep: u32,
|
||||
}
|
||||
|
||||
impl UsbIpHeaderBasic {
|
||||
/// Converts a byte array into a [UsbIpHeaderBasic].
|
||||
pub fn from_bytes(bytes: &[u8; 20]) -> Self {
|
||||
let result = UsbIpHeaderBasic {
|
||||
command: u32::from_be_bytes(bytes[0..4].try_into().unwrap()),
|
||||
seqnum: u32::from_be_bytes(bytes[4..8].try_into().unwrap()),
|
||||
devid: u32::from_be_bytes(bytes[8..12].try_into().unwrap()),
|
||||
direction: u32::from_be_bytes(bytes[12..16].try_into().unwrap()),
|
||||
ep: u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
|
||||
};
|
||||
// The direction should be 0 or 1
|
||||
debug_assert!(result.direction & 1 == result.direction);
|
||||
result
|
||||
}
|
||||
|
||||
/// Converts the [UsbIpHeaderBasic] into a byte array.
|
||||
pub fn to_bytes(&self) -> [u8; 20] {
|
||||
let mut result = [0u8; 20];
|
||||
result[0..4].copy_from_slice(&self.command.to_be_bytes());
|
||||
result[4..8].copy_from_slice(&self.seqnum.to_be_bytes());
|
||||
result[8..12].copy_from_slice(&self.devid.to_be_bytes());
|
||||
result[12..16].copy_from_slice(&self.direction.to_be_bytes());
|
||||
result[16..20].copy_from_slice(&self.ep.to_be_bytes());
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn read_from_socket_with_command<T: AsyncReadExt + Unpin>(
|
||||
socket: &mut T,
|
||||
command: u16,
|
||||
) -> Result<Self> {
|
||||
let seqnum = socket.read_u32().await?;
|
||||
let devid = socket.read_u32().await?;
|
||||
let direction = socket.read_u32().await?;
|
||||
// The direction should be 0 or 1
|
||||
debug_assert!(direction & 1 == direction);
|
||||
let ep = socket.read_u32().await?;
|
||||
|
||||
Ok(UsbIpHeaderBasic {
|
||||
command: command.into(),
|
||||
seqnum,
|
||||
devid,
|
||||
direction,
|
||||
ep,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Client side commands from the Virtual Host Controller
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum UsbIpCommand {
|
||||
OpReqDevlist {
|
||||
status: u32,
|
||||
},
|
||||
OpReqImport {
|
||||
status: u32,
|
||||
busid: [u8; 32],
|
||||
},
|
||||
UsbIpCmdSubmit {
|
||||
header: UsbIpHeaderBasic,
|
||||
transfer_flags: u32,
|
||||
transfer_buffer_length: u32,
|
||||
start_frame: u32,
|
||||
number_of_packets: u32,
|
||||
interval: u32,
|
||||
setup: [u8; 8],
|
||||
data: Vec<u8>,
|
||||
iso_packet_descriptor: Vec<u8>,
|
||||
},
|
||||
UsbIpCmdUnlink {
|
||||
header: UsbIpHeaderBasic,
|
||||
unlink_seqnum: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl UsbIpCommand {
|
||||
/// Constructs a [UsbIpCommand] from a socket
|
||||
///
|
||||
/// This will consume a variable amount of bytes from the socket.
|
||||
/// It might fail if the bytes does not follow the USB/IP protocol properly.
|
||||
pub async fn read_from_socket<T: AsyncReadExt + Unpin>(socket: &mut T) -> Result<UsbIpCommand> {
|
||||
let version: u16 = socket.read_u16().await?;
|
||||
|
||||
if version != 0 && version != USBIP_VERSION {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Unknown version: {version:#04X}"
|
||||
)));
|
||||
}
|
||||
|
||||
let command: u16 = socket.read_u16().await?;
|
||||
|
||||
trace!(
|
||||
"Received command: {:#04X} ({}), parsing...",
|
||||
command,
|
||||
match command {
|
||||
OP_REQ_DEVLIST => "OP_REQ_DEVLIST",
|
||||
OP_REQ_IMPORT => "OP_REQ_IMPORT",
|
||||
USBIP_CMD_SUBMIT => "USBIP_CMD_SUBMIT",
|
||||
USBIP_CMD_UNLINK => "USBIP_CMD_UNLINK",
|
||||
_ => "Unknown",
|
||||
}
|
||||
);
|
||||
|
||||
match command {
|
||||
OP_REQ_DEVLIST => {
|
||||
let status = socket.read_u32().await?;
|
||||
debug_assert!(status == 0);
|
||||
|
||||
Ok(UsbIpCommand::OpReqDevlist { status })
|
||||
}
|
||||
OP_REQ_IMPORT => {
|
||||
let status = socket.read_u32().await?;
|
||||
debug_assert!(status == 0);
|
||||
let mut busid = [0; 32];
|
||||
socket.read_exact(&mut busid).await?;
|
||||
|
||||
Ok(UsbIpCommand::OpReqImport { status, busid })
|
||||
}
|
||||
USBIP_CMD_SUBMIT => {
|
||||
let header =
|
||||
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_SUBMIT)
|
||||
.await?;
|
||||
let transfer_flags = socket.read_u32().await?;
|
||||
let transfer_buffer_length = socket.read_u32().await?;
|
||||
let start_frame = socket.read_u32().await?;
|
||||
let number_of_packets = socket.read_u32().await?;
|
||||
let interval = socket.read_u32().await?;
|
||||
|
||||
let mut setup = [0; 8];
|
||||
socket.read_exact(&mut setup).await?;
|
||||
|
||||
let data = if header.direction == Direction::In as u32 {
|
||||
vec![]
|
||||
} else {
|
||||
let mut data = vec![0; transfer_buffer_length as usize];
|
||||
socket.read_exact(&mut data).await?;
|
||||
data
|
||||
};
|
||||
|
||||
// The kernel docs specifies that this should be set to 0xFFFFFFFF for all
|
||||
// non-ISO packets, however the actual implementation resorts to 0x00000000
|
||||
// https://stackoverflow.com/questions/76899798/usb-ip-what-is-the-size-of-the-iso-packet-descriptor
|
||||
let iso_packet_descriptor =
|
||||
if number_of_packets != 0 && number_of_packets != 0xFFFFFFFF {
|
||||
let mut result = vec![0; 16 * number_of_packets as usize];
|
||||
socket.read_exact(&mut result).await?;
|
||||
result
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(UsbIpCommand::UsbIpCmdSubmit {
|
||||
header,
|
||||
transfer_flags,
|
||||
transfer_buffer_length,
|
||||
start_frame,
|
||||
number_of_packets,
|
||||
interval,
|
||||
setup,
|
||||
data,
|
||||
iso_packet_descriptor,
|
||||
})
|
||||
}
|
||||
USBIP_CMD_UNLINK => {
|
||||
let header =
|
||||
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_UNLINK)
|
||||
.await?;
|
||||
let unlink_seqnum = socket.read_u32().await?;
|
||||
|
||||
let mut _padding = [0; 24];
|
||||
socket.read_exact(&mut _padding).await?;
|
||||
|
||||
Ok(UsbIpCommand::UsbIpCmdUnlink {
|
||||
header,
|
||||
unlink_seqnum,
|
||||
})
|
||||
}
|
||||
_ => Err(std::io::Error::other(format!(
|
||||
"Unknown command: {command:#04X}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the [UsbIpCommand] into a byte vector
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
match *self {
|
||||
UsbIpCommand::OpReqDevlist { status } => {
|
||||
let mut result = Vec::with_capacity(8);
|
||||
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
result.extend_from_slice(&OP_REQ_DEVLIST.to_be_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
result
|
||||
}
|
||||
UsbIpCommand::OpReqImport { status, busid } => {
|
||||
let mut result = Vec::with_capacity(40);
|
||||
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
result.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
result.extend_from_slice(&busid);
|
||||
result
|
||||
}
|
||||
UsbIpCommand::UsbIpCmdSubmit {
|
||||
ref header,
|
||||
transfer_flags,
|
||||
transfer_buffer_length,
|
||||
start_frame,
|
||||
number_of_packets,
|
||||
interval,
|
||||
setup,
|
||||
ref data,
|
||||
ref iso_packet_descriptor,
|
||||
} => {
|
||||
debug_assert!(
|
||||
header.direction != Direction::Out as u32
|
||||
|| transfer_buffer_length == data.len() as u32
|
||||
);
|
||||
|
||||
let mut result = Vec::with_capacity(48 + data.len() + iso_packet_descriptor.len());
|
||||
result.extend_from_slice(&header.to_bytes());
|
||||
result.extend_from_slice(&transfer_flags.to_be_bytes());
|
||||
result.extend_from_slice(&transfer_buffer_length.to_be_bytes());
|
||||
result.extend_from_slice(&start_frame.to_be_bytes());
|
||||
result.extend_from_slice(&number_of_packets.to_be_bytes());
|
||||
result.extend_from_slice(&interval.to_be_bytes());
|
||||
result.extend_from_slice(&setup);
|
||||
result.extend_from_slice(data);
|
||||
result.extend_from_slice(iso_packet_descriptor);
|
||||
result
|
||||
}
|
||||
UsbIpCommand::UsbIpCmdUnlink {
|
||||
ref header,
|
||||
unlink_seqnum,
|
||||
} => {
|
||||
let mut result = Vec::with_capacity(48);
|
||||
result.extend_from_slice(&header.to_bytes());
|
||||
result.extend_from_slice(&unlink_seqnum.to_be_bytes());
|
||||
result.extend_from_slice(&[0; 24]);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server side responses from the USB Host
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||
pub enum UsbIpResponse {
|
||||
OpRepDevlist {
|
||||
status: u32,
|
||||
device_count: u32,
|
||||
devices: Vec<UsbDevice>,
|
||||
},
|
||||
OpRepImport {
|
||||
status: u32,
|
||||
device: Option<UsbDevice>,
|
||||
},
|
||||
UsbIpRetSubmit {
|
||||
header: UsbIpHeaderBasic,
|
||||
status: u32,
|
||||
actual_length: u32,
|
||||
start_frame: u32,
|
||||
number_of_packets: u32,
|
||||
error_count: u32,
|
||||
transfer_buffer: Vec<u8>,
|
||||
iso_packet_descriptor: Vec<u8>,
|
||||
},
|
||||
UsbIpRetUnlink {
|
||||
header: UsbIpHeaderBasic,
|
||||
status: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl UsbIpResponse {
|
||||
/// Converts the [UsbIpResponse] into a byte vector
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
match *self {
|
||||
Self::OpRepDevlist {
|
||||
status,
|
||||
device_count,
|
||||
ref devices,
|
||||
} => {
|
||||
let mut result = Vec::with_capacity(
|
||||
12 + devices.len() * 312
|
||||
+ devices
|
||||
.iter()
|
||||
.map(|d| d.interfaces.len() * 4)
|
||||
.sum::<usize>(),
|
||||
);
|
||||
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
result.extend_from_slice(&OP_REP_DEVLIST.to_be_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
result.extend_from_slice(&device_count.to_be_bytes());
|
||||
for dev in devices {
|
||||
result.extend_from_slice(&dev.to_bytes_with_interfaces());
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::OpRepImport { status, ref device } => {
|
||||
let mut result = Vec::with_capacity(320);
|
||||
result.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
result.extend_from_slice(&OP_REP_IMPORT.to_be_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
if let Some(device) = device {
|
||||
result.extend_from_slice(&device.to_bytes());
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::UsbIpRetSubmit {
|
||||
ref header,
|
||||
status,
|
||||
actual_length,
|
||||
start_frame,
|
||||
number_of_packets,
|
||||
error_count,
|
||||
ref transfer_buffer,
|
||||
ref iso_packet_descriptor,
|
||||
} => {
|
||||
let mut result =
|
||||
Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len());
|
||||
|
||||
debug_assert!(header.command == USBIP_RET_SUBMIT.into());
|
||||
debug_assert!(if header.direction == Direction::In as u32 {
|
||||
actual_length == transfer_buffer.len() as u32
|
||||
} else {
|
||||
actual_length == 0
|
||||
});
|
||||
|
||||
result.extend_from_slice(&header.to_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
result.extend_from_slice(&actual_length.to_be_bytes());
|
||||
result.extend_from_slice(&start_frame.to_be_bytes());
|
||||
result.extend_from_slice(&number_of_packets.to_be_bytes());
|
||||
result.extend_from_slice(&error_count.to_be_bytes());
|
||||
result.extend_from_slice(&[0; 8]);
|
||||
result.extend_from_slice(transfer_buffer);
|
||||
result.extend_from_slice(iso_packet_descriptor);
|
||||
result
|
||||
}
|
||||
Self::UsbIpRetUnlink { ref header, status } => {
|
||||
let mut result = Vec::with_capacity(48);
|
||||
|
||||
debug_assert!(header.command == USBIP_RET_UNLINK.into());
|
||||
|
||||
result.extend_from_slice(&header.to_bytes());
|
||||
result.extend_from_slice(&status.to_be_bytes());
|
||||
result.extend_from_slice(&[0; 24]);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_to_socket<T: AsyncWriteExt + Unpin>(&self, socket: &mut T) -> Result<()> {
|
||||
socket.write_all(&self.to_bytes()).await
|
||||
}
|
||||
|
||||
/// Constructs a OP_REP_DEVLIST response
|
||||
pub fn op_rep_devlist(devices: &[UsbDevice]) -> Self {
|
||||
Self::OpRepDevlist {
|
||||
status: 0,
|
||||
device_count: devices.len() as u32,
|
||||
devices: devices.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a successful OP_REP_IMPORT response
|
||||
pub fn op_rep_import_success(device: &UsbDevice) -> Self {
|
||||
Self::OpRepImport {
|
||||
status: 0,
|
||||
device: Some(device.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a failed OP_REP_IMPORT response
|
||||
pub fn op_rep_import_fail() -> Self {
|
||||
Self::OpRepImport {
|
||||
status: 1,
|
||||
device: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a successful OP_REP_IMPORT response
|
||||
pub fn usbip_ret_submit_success(
|
||||
header: &UsbIpHeaderBasic,
|
||||
start_frame: u32,
|
||||
number_of_packets: u32,
|
||||
transfer_buffer: Vec<u8>,
|
||||
iso_packet_descriptor: Vec<u8>,
|
||||
) -> Self {
|
||||
Self::UsbIpRetSubmit {
|
||||
header: header.clone(),
|
||||
status: 0,
|
||||
actual_length: transfer_buffer.len() as u32,
|
||||
start_frame,
|
||||
number_of_packets,
|
||||
error_count: 0,
|
||||
transfer_buffer,
|
||||
iso_packet_descriptor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a failed OP_REP_IMPORT response
|
||||
pub fn usbip_ret_submit_fail(header: &UsbIpHeaderBasic) -> Self {
|
||||
Self::UsbIpRetSubmit {
|
||||
header: header.clone(),
|
||||
status: 1,
|
||||
actual_length: 0,
|
||||
start_frame: 0,
|
||||
number_of_packets: 0,
|
||||
error_count: 0,
|
||||
transfer_buffer: vec![],
|
||||
iso_packet_descriptor: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a successful OP_REP_IMPORT response
|
||||
pub fn usbip_ret_unlink_success(header: &UsbIpHeaderBasic) -> Self {
|
||||
Self::UsbIpRetUnlink {
|
||||
header: header.clone(),
|
||||
status: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a failed OP_REP_IMPORT response.
|
||||
pub fn usbip_ret_unlink_fail(header: &UsbIpHeaderBasic) -> Self {
|
||||
Self::UsbIpRetUnlink {
|
||||
header: header.clone(),
|
||||
status: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||
@@ -0,0 +1,10 @@
|
||||
/// Check validity of a USB descriptor
|
||||
pub fn verify_descriptor(desc: &[u8]) {
|
||||
let mut offset = 0;
|
||||
while offset < desc.len() {
|
||||
offset += desc[offset] as usize; // length
|
||||
}
|
||||
assert_eq!(offset, desc.len());
|
||||
}
|
||||
|
||||
// (In-crate test module removed in the vendored copy — see NOTICE.)
|
||||
@@ -34,6 +34,8 @@ holds the full originals.
|
||||
| [`apple-stage2-presenter.md`](apple-stage2-presenter.md) | Apple stage-2 (VTDecompressionSession + CAMetalLayer) presenter | **Shipped (opt-in)** — make-default + iOS open |
|
||||
| [`game-library-stores.md`](game-library-stores.md) | Multi-store game library | **Phases 1–4 shipped** — 6 providers + 8 Qs open |
|
||||
| [`dualsense-haptics.md`](dualsense-haptics.md) | DualSense advanced-haptics feasibility | **HID shipped**; audio haptics deferred (3 walls) |
|
||||
| [`steam-controller-deck-support.md`](steam-controller-deck-support.md) | Rich Steam Controller / Steam Deck **input fidelity** (paddles · trackpads · gyro → virtual `hid-steam`) | **Design + M0 GREEN** (Linux bind proven); M1+ open |
|
||||
| [`controller-only-mode.md`](controller-only-mode.md) | Controller-only **session shape** — Deck/desktop as a remote gamepad, no video/audio (complements ↑) | **Design** — not yet implemented |
|
||||
| [`archive/windows-secure-desktop.md`](archive/windows-secure-desktop.md) | Two-process WGC secure-desktop design | **Archived** — shipped but now a fallback (IDD-push primary) |
|
||||
|
||||
Plus `research/gamestream-protocol-research.json` — raw Moonlight/GameStream wire reference (data, not prose).
|
||||
@@ -74,6 +76,10 @@ owning doc.)
|
||||
**Game library**
|
||||
- 6 remaining providers (Desktop/Flatpak, itch.io, Ubisoft Connect, Amazon Games, Battle.net, EA app); the `/library/art/<entryId>/<slot>` mgmt endpoint; refactor `library.rs` into a `library/` dir; 8 open design questions; optional SteamGridDB v2 enrichment. → `game-library-stores`
|
||||
|
||||
**Controllers / input**
|
||||
- Rich Steam Controller / Steam Deck capture + virtual `hid-steam` inject (M1+ — Linux UHID, then clients, then deferred Windows UMDF). → `steam-controller-deck-support`
|
||||
- Controller-only session shape (Deck/desktop as a remote gamepad, no video/audio) — `session_flags`/`SESSION_INPUT_ONLY` protocol bit + host skip-data-plane branch + client controller-only path. → `controller-only-mode`
|
||||
|
||||
**Multi-user / sessions**
|
||||
- gamescope per-session input/audio isolation (independent desktops) — the 4 plumbing items, deferred. → `gamescope-multiuser`, `implementation-plan`
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# Controller-only mode (Deck / desktop as a remote gamepad)
|
||||
|
||||
> **Status:** **DESIGN — not yet implemented.** Locked decisions (2026-06-29): build the
|
||||
> **full-fidelity** path directly (no plain-Xbox interim), capture this as a doc before code.
|
||||
> This is the **session-shape** complement to `design/steam-controller-deck-support.md` (which is
|
||||
> the **input-fidelity** work: capturing the Deck's paddles/trackpads/gyro and injecting a virtual
|
||||
> `hid-steam`/DualSense device). Controller-only mode reuses that capture + inject pipeline
|
||||
> verbatim; it only adds a negotiated "no video / no audio" session shape so the Deck can be a
|
||||
> wireless gamepad for a PC **without** the wasteful return video stream.
|
||||
|
||||
## 1. Goal + the use case
|
||||
|
||||
Let a punktfunk client (a Steam Deck, but also any desktop with a controller) connect to a
|
||||
punktfunk host and forward **only controller input** — no video stream, no audio stream. The host
|
||||
user watches their **own** monitor; the Deck is just a wireless, full-fidelity gamepad. Rumble /
|
||||
lightbar / adaptive-trigger / HID feedback still flows back on the existing side planes.
|
||||
|
||||
Concretely this turns the Deck into:
|
||||
|
||||
- a **couch controller** for a PC wired to a TV — gyro aim, both trackpads, the 4 back grips, with
|
||||
**lower** latency than streaming (no encode/decode round-trip at all), and no bandwidth wasted on
|
||||
a video feed you aren't looking at;
|
||||
- one of **several** Decks/pads driving one shared-screen PC for local co-op (rides the multi-pad
|
||||
work already in flight);
|
||||
- a way to use the Deck's superior input surface (trackpads + gyro) on a game running on a
|
||||
beefier host.
|
||||
|
||||
**Non-goal (for v1):** forwarding a real keyboard/mouse. The Deck's trackpads/gyro are carried as
|
||||
*gamepad* fidelity (DualSense/Steam touchpad + motion planes), which is display-independent (§5).
|
||||
A genuine keyboard/mouse forward rides the libei/portal pointer path, which is *not*
|
||||
display-independent — deferred to a follow-on (§9).
|
||||
|
||||
## 2. Does SteamOS already do this? — No, and that's the opening
|
||||
|
||||
Researched 2026-06-29 (web). Summary of why this is worth building:
|
||||
|
||||
- **Officially**, Valve's only endorsed "Deck as a controller for another PC" path is **Steam
|
||||
Remote Play / Steam Link in reverse** — but it **always streams the game's video to the Deck**
|
||||
even though you're looking at the PC's monitor. A dedicated controller-only mode has been
|
||||
requested since **July 2022** (Steam community thread) and the matching `ValveSoftware/SteamOS`
|
||||
issue **#1623 is "Closed as not planned."** SteamOS 3.8 (Jun 2026) and the new standalone Steam
|
||||
Controller (2026 hardware) did **not** add it.
|
||||
- **Community/DIY** splits three ways, all low-popularity (25–129★), several stale or "currently
|
||||
broken": USB-C HID gadget (**GadgetDeck** — wired-only, BIOS Dual-Role toggle, no gyro/trackpad,
|
||||
unmaintained), Bluetooth HID (**steamdeck-bt-controller-emulator** — pairing/recognition pain + BT
|
||||
latency), and network (**Deckpad** → commercial *paid* VirtualHere USB-over-IP, "currently
|
||||
broken"; **swicd-remote-gamepad** → ViGEm, Windows-only, experimental).
|
||||
- **The ceiling every existing tool hits:** none cleanly carries the Deck's **gyro + dual trackpads
|
||||
+ the 4 back buttons (L4/L5/R4/R5)** to the remote host, because Steam's emulated Xbox pad
|
||||
(`28DE:11FF`) hides them.
|
||||
|
||||
punktfunk is uniquely positioned: it already has a low-latency QUIC input back-channel, host-side
|
||||
virtual Xbox-360 / DualSense / DS4 (and, in flight, `hid-steam`) pad injection with rumble/lightbar/
|
||||
adaptive-trigger feedback, and SDL3 capture. Controller-only mode + the
|
||||
`steam-controller-deck-support.md` capture work together deliver exactly the gap nobody else fills.
|
||||
|
||||
Sources (load-bearing): SteamOS issue #1623 (closed not-planned)
|
||||
<https://github.com/ValveSoftware/SteamOS/issues/1623>; Steam community controller-only-mode request
|
||||
<https://steamcommunity.com/app/1675200/discussions/2/3466100515592011642/>; Valve FAQ
|
||||
<https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2>; GadgetDeck
|
||||
<https://github.com/Frederic98/GadgetDeck>; Deckpad <https://github.com/HelloThisIsFlo/Deckpad>;
|
||||
Steam Deck HID deep-dive <https://blogs.gnome.org/alicem/2024/10/24/steam-deck-hid-and-libmanette-adventures/>.
|
||||
|
||||
## 3. The key synergy: controller-only mode *dissolves* the Game-Mode capture wall
|
||||
|
||||
`steam-controller-deck-support.md` §6 / Wall A: on the Deck, SDL3's HIDAPI driver can open the raw
|
||||
`28DE:1205` and expose paddles + both trackpads + gyro as a first-class SDL gamepad — **but in Deck
|
||||
Game Mode, Steam Input grabs the device exclusively** and re-presents it as the gutted `28DE:11FF`
|
||||
virtual XInput pad, so the rich controls silently vanish. The only escape there is the
|
||||
disable-Steam-Input-per-title UX.
|
||||
|
||||
**Controller-only mode's natural launch context avoids that wall entirely.** The use case is "the
|
||||
Deck is a controller, no game runs on the Deck" → it runs as a **desktop-mode / standalone app**,
|
||||
where Steam Input is **not** managing the internal pad, so SDL3 binds `28DE:1205` and gets full
|
||||
fidelity with no UX gymnastics. So the two features are mutually reinforcing: controller-only mode
|
||||
is the very scenario in which full-fidelity Deck capture "just works."
|
||||
|
||||
The capture-side rule is therefore the same one §6 documents, and the client **must verify at
|
||||
runtime it opened `28DE:1205` (HIDAPI GUID ending `6800`), not `28DE:11FF`** — if it only sees
|
||||
`11FF`, Steam owns the pad and gyro/trackpad/grips are unavailable; surface that to the user.
|
||||
|
||||
## 4. Architecture — input plane is already decoupled from video
|
||||
|
||||
A punktfunk/1 native session already runs **two independent transports** (verified in-tree):
|
||||
|
||||
- a **QUIC** control connection that carries the `Hello`/`Welcome`/`Start` handshake **and every
|
||||
side plane** as datagrams demuxed by first byte: input `0xC8`, rich input `0xCC` (DualSense/Deck
|
||||
touchpad + motion), mic uplink `0xCB`, audio `0xC9`, rumble `0xCA`, HID-out `0xCD`;
|
||||
- a **raw-UDP data plane** (`Session`, FEC + AES-GCM) that carries **only** the video AUs.
|
||||
|
||||
**Input never touches the UDP data plane** — it rides QUIC datagrams. So an input-only session is
|
||||
"run the QUIC handshake + side planes, never bind/open the UDP data plane." The honest work is
|
||||
making **both** ends *skip the data plane* and, on the host, *not spin up a virtual display +
|
||||
encoder* (a desktop PC the operator is watching has no reason to allocate a headless virtual output
|
||||
or burn an NVENC slot).
|
||||
|
||||
Critically: **gamepads are system-global kernel devices, not tied to any virtual output.** The
|
||||
per-session `PadBackend` (`punktfunk1.rs:1396`) creates an Xbox-360 pad on `/dev/uinput`, or a
|
||||
DualSense/DS4/Steam pad on `/dev/uhid` — all visible to Steam/Proton/every game on the host's real
|
||||
seat with **zero** display involvement. Rumble/HID feedback (`0xCA`/`0xCD`) and DualSense/Deck
|
||||
touchpad+motion (`0xCC` rich input, `apply_rich` at `:1467`/`:1606`) flow on the same per-session
|
||||
input thread, also display-independent. So a controller-only session needs **no virtual display, no
|
||||
compositor, no portal grant, no encoder** — just the input thread + the pad backend.
|
||||
|
||||
`clients/probe --input-test` already proves the shape: it connects, streams scripted gamepad
|
||||
datagrams the host injects into a real pad, and never decodes video.
|
||||
|
||||
## 5. Protocol / ABI change — one `session_flags` byte (additive, fwd-compatible)
|
||||
|
||||
Reuse the **exact** trailing-byte back-compat discipline `Hello`/`Welcome` already apply to
|
||||
`compositor`/`gamepad`/`video_caps`/`audio_channels` (`quic.rs:655-882`). Add a **session-flags**
|
||||
byte as the new last trailing field on both.
|
||||
|
||||
```
|
||||
quic.rs (core):
|
||||
pub const SESSION_INPUT_ONLY: u8 = 0x01; // bit 0 of session_flags
|
||||
|
||||
Hello { …, audio_channels: u8, session_flags: u8 } // new trailing byte after audio_channels
|
||||
Welcome { …, audio_channels: u8, session_flags: u8 } // echoes the resolved shape (offset 66)
|
||||
```
|
||||
|
||||
### 5.1 Encode (placeholder discipline)
|
||||
|
||||
`Hello::encode` already emits `video_caps` / `audio_channels` only when non-default, emitting
|
||||
upstream placeholders so each lands at a deterministic offset. Extend the same logic:
|
||||
|
||||
```
|
||||
need_placeholders = video_caps != 0 || audio_channels != 2 || session_flags != 0;
|
||||
// video_caps emitted when video_caps != 0 || audio_channels != 2 || session_flags != 0
|
||||
// audio_channels emitted when audio_channels != 2 || session_flags != 0
|
||||
// session_flags emitted when session_flags != 0 (one more trailing byte, after audio_channels)
|
||||
```
|
||||
|
||||
`Hello::decode` reads it one past `audio_channels` (i.e. `video_caps_off + 2`), defaulting to `0`
|
||||
(no flags) when absent → an older peer requests an ordinary video session, byte-identical wire.
|
||||
`Welcome` is simpler (fixed-position trailing bytes): append `session_flags` at **offset 66**,
|
||||
`b.get(66).copied().unwrap_or(0)`.
|
||||
|
||||
> **Why a flags byte, not an overloaded `Mode{0,0,0}` sentinel:** a flag is explicit and leaves
|
||||
> room for future session-shape bits (e.g. audio-only, input+audio). `Mode` stays strictly a
|
||||
> display mode. (Open question 7.1 — confirm with the user, but this is the recommendation.)
|
||||
|
||||
### 5.2 ABI
|
||||
|
||||
- New `connect_ex` rung in the existing ladder (the `connect_exN` precedent) that takes a
|
||||
`session_flags` (or a bool `input_only`) and stores it on `NativeClient`; legacy `connect_ex*`
|
||||
stay byte-for-byte. Regenerate `include/punktfunk_core.h` (CI fails on drift).
|
||||
- New constant `PUNKTFUNK_SESSION_INPUT_ONLY = 0x01`.
|
||||
- `next_au` / `next_frame` / `next_audio` simply return `NoFrame`/`Closed` in an input-only
|
||||
connection; `send_input` / `send_rich_input` / `next_rumble` / `next_hidout` are unchanged — they
|
||||
are already the full input-only surface.
|
||||
|
||||
## 6. Host changes — `serve_session` branch (`punktfunk1.rs:508`)
|
||||
|
||||
Branch on `hello.session_flags & SESSION_INPUT_ONLY`. When set:
|
||||
|
||||
| Step | Today (video session) | Input-only |
|
||||
|---|---|---|
|
||||
| `--max-concurrent` permit (`:640` `_permit`) | acquired (NVENC slot) | **skip** — input-only must not consume a GPU slot (§7.4) |
|
||||
| `validate_dimensions` (`:654`) | required | **skip** |
|
||||
| `resolve_compositor` (`:668`) | required (Virtual source) | **skip** — no virtual display |
|
||||
| bit-depth / chroma / HDR / 444 probes, bitrate clamp | run | **skip** |
|
||||
| `Welcome` (`:794`) | real mode + udp_port + caps | **sentinel** mode `{0,0,0}` + `udp_port=0` + `session_flags=INPUT_ONLY`; still carries the resolved `gamepad` backend |
|
||||
| `Start::decode` (`:845`) | read client udp port | read + **ignore** (harmless no-op) |
|
||||
| `input_thread` spawn (`:980`) | spawn | **spawn (unchanged)** — this is the whole point |
|
||||
| client→host datagram demux (`:989`) | spawn | **spawn (unchanged)** — `0xC8`/`0xCC`/`0xCB` in, `0xCA`/`0xCD` out |
|
||||
| `audio_thread` (`:1032`, already gated on `source==Virtual`) | spawn | **skip** (add `&& !input_only`) |
|
||||
| `virtual_stream(SessionContext{…})` (`:1155`) — the only place a display+encoder open and the UDP socket binds | run | **replace** with `await stop / conn.closed()` — no UDP bind happens (the bind lives inside that block), no display, no encoder |
|
||||
| teardown (`:1190+`) | releases input thread + pads + held keys | **unchanged** — already correct |
|
||||
|
||||
Net host change is mostly **guards and deletions in one function**; no hot-path, FEC, crypto, or
|
||||
injector changes. `cfg`-clean on Windows (no compositor/`virtual_stream` there either; the XUSB/UMDF
|
||||
pads are likewise system-global, SendInput is irrelevant in a gamepad-only mode).
|
||||
|
||||
### 6.1 Pad backend / fidelity (reuses `steam-controller-deck-support.md` verbatim)
|
||||
|
||||
The full-fidelity path is **entirely** the in-flight Deck/DualSense inject work — controller-only
|
||||
mode adds nothing here, it just runs it without video:
|
||||
|
||||
- Deck → host resolves the **Steam `hid-steam`** backend (Linux UHID) when available so Steam Input
|
||||
re-emits `28DE:11FF` with the user's bindings + correct glyphs; trackpads → `RichInput::TouchpadEx`
|
||||
(`0xCC 0x03`), gyro/accel → `RichInput::Motion` (`0xCC 0x02`), back grips → `BTN_PADDLE1..4`
|
||||
(`0xC8` bits). See that doc §4–§7.
|
||||
- Where a real Steam pad is unavailable, the **DualSense remap fallback** (`steam_remap.rs`) folds
|
||||
Steam-only inputs into a virtual DualSense (gyro→motion, right pad→touchpad, grips→configured
|
||||
fallback) so nothing is silently dropped.
|
||||
- `GamepadPref` resolution policy is that doc's §7 — **unchanged**; the Welcome echoes the real
|
||||
resolved backend (honest downgrade).
|
||||
|
||||
## 7. Client changes
|
||||
|
||||
### 7.1 Core connector (`client.rs::worker_main:714`)
|
||||
|
||||
Thread an `input_only` flag in. When set: do the full `Hello`/`Welcome` handshake and spawn the
|
||||
input/mic/rich/ctrl/datagram-demux tasks (so `send_input`/`send_rich_input` + `next_rumble`/
|
||||
`next_hidout` keep working), but **skip** the UDP port reservation + `Start`-derived
|
||||
`UdpTransport::connect` + `Session` + the data-plane pump (`:810-849,1041`). `next_frame`/
|
||||
`next_audio` return `Closed`/`NoFrame`.
|
||||
|
||||
### 7.2 Deck / Linux client (`clients/linux`) — a "Use as controller" path
|
||||
|
||||
Add a connect path that opens the connection **input-only** and runs **only** the app-lifetime
|
||||
`GamepadService` (`clients/linux/src/gamepad.rs`) — it already `attach`es the connector, forwards
|
||||
pads via `send_input` (`:161`), DualSense/Deck touchpad+motion via `send_rich_input`, and drains
|
||||
`next_rumble` (`:566`)/`next_hidout` (`:583`). **Do not** run `session.rs`'s video/audio pump
|
||||
(`:135-200`) — no decoder, no video window, no PipeWire player. UI is a minimal "Connected as
|
||||
controller — <backend> · <host>" status surface (battery, latency, a Disconnect button), no video
|
||||
widget.
|
||||
|
||||
On the Deck specifically: set `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` before `sdl3::init()`,
|
||||
resolve `GamepadPref::SteamDeck` from VID `0x28DE`, and **assert the opened device is `28DE:1205`
|
||||
(HIDAPI GUID `…6800`), not `11FF`** — if `11FF`, show "Steam is managing this controller; full
|
||||
gyro/trackpad/grip fidelity needs desktop mode / Steam Input off." (Capture mechanics =
|
||||
`steam-controller-deck-support.md` §6.)
|
||||
|
||||
### 7.3 Other clients
|
||||
|
||||
`clients/windows` gets the same input-only connect + SDL `GamepadService`-only path (XUSB/UMDF pads
|
||||
are system-global; no SwapChainPanel/decode). Apple/Android: parity is optional and lower-priority —
|
||||
the connector ABI already exposes everything; a "controller-only" UI mode is a small add once the
|
||||
core flag lands. Scope per the user's roadmap, not blocking.
|
||||
|
||||
### 7.4 Session lifetime / accounting
|
||||
|
||||
An input-only session is long-lived (until disconnect), like a video session, but must **not**
|
||||
count against `--max-concurrent` (it holds no NVENC) and should be **exempt** from any `--seconds`
|
||||
duration cap. The mgmt API / web console should list it distinctly (it is **not** a "stream" — no
|
||||
fps/bitrate/mode), and `--max-sessions` accounting should likely treat it as its own class
|
||||
(open question 8.x). mDNS advertisement is unchanged (the host already advertises native service;
|
||||
input-only is a per-session negotiation, not a service variant).
|
||||
|
||||
## 8. Security / trust — unchanged
|
||||
|
||||
A controller-only session is a **full punktfunk/1 session**: same SPAKE2 PIN pairing / TOFU /
|
||||
`--require-pairing` gate, same QUIC client-auth + pinned-fingerprint trust. The *only* difference is
|
||||
the absent data plane. No new attack surface — if anything less (no UDP socket, no FEC reassembler,
|
||||
no decoder fed attacker bytes). The host still requires `/dev/uinput` (+ `/dev/uhid` for DualSense/
|
||||
Steam) writable — the documented `input` group + `60-punktfunk.rules` setup.
|
||||
|
||||
## 9. Milestones
|
||||
|
||||
- **M1 — protocol + ABI:** `session_flags` byte + `SESSION_INPUT_ONLY` on `Hello`/`Welcome` with
|
||||
round-trip + old-peer-default unit tests; new `connect_ex` rung; regenerate the C header.
|
||||
- **M2 — host branch:** `serve_session` input-only path (skip display/encoder/audio/permit, keep
|
||||
input thread + pads), `cfg`-clean on Windows. Loopback test: handshake completes, **no UDP data
|
||||
plane binds**, gamepad events still inject into a real uinput pad.
|
||||
- **M3 — Deck/Linux client:** "Use as controller" connect + `GamepadService`-only run; `28DE:1205`
|
||||
vs `11FF` runtime check + user messaging.
|
||||
- **M4 — full fidelity on-glass:** with `steam-controller-deck-support.md`'s Deck capture + inject,
|
||||
validate Deck (desktop mode) → host: paddles + both trackpads + gyro reach the host pad, Steam
|
||||
Input re-emits with bindings/glyphs, rumble returns. Glass-to-glass input latency vs a wired pad.
|
||||
- **M5 — Windows client parity + mgmt/web "controller session" surfacing.**
|
||||
- **M6 (deferred) — keyboard/mouse forward** over the libei/portal path (needs the active-session
|
||||
RemoteDesktop grant; not display-independent).
|
||||
|
||||
## 10. Risks / open questions
|
||||
|
||||
**Open questions (decide with the user):**
|
||||
|
||||
1. **`session_flags` byte vs `Mode{0,0,0}` sentinel** for "no video" — recommend the explicit flags
|
||||
byte (room for future shapes). *(Recommended; confirm.)*
|
||||
2. Should an input-only session be **exempt** from `--max-concurrent` and `--seconds`? (Recommend
|
||||
yes — it holds no GPU.)
|
||||
3. Should mgmt/web track controller-only sessions as a distinct class (no fps/bitrate/mode), and
|
||||
should `--max-sessions` count them?
|
||||
4. Deck default backend: **Steam `hid-steam`** (best — Steam Input bindings/glyphs) vs **DualSense**
|
||||
(works off-Steam too). Tie to `steam-controller-deck-support.md`'s resolution policy.
|
||||
|
||||
**Risks:**
|
||||
|
||||
- **Capture wall (inherited):** full fidelity requires SDL to bind `28DE:1205`; if the Deck is in
|
||||
Game Mode / Steam owns the pad, it degrades to `11FF` (sticks/buttons only). Mitigated by the
|
||||
desktop-mode use case + runtime check + user messaging (§3, §7.2).
|
||||
- **Host `/dev/uinput`(+`/dev/uhid`) perms** — a normal desktop PC operator must do the one-time
|
||||
input-group/udev setup for the virtual pad to appear (already documented).
|
||||
- **Handshake assumes a data plane:** `Start{client_udp_port}` + non-zero `udp_port` in `Welcome`
|
||||
must become harmless no-ops (send 0 / ignore) without shifting the legacy wire — the trailing-byte
|
||||
placeholder discipline is fragile; **add round-trip + old-peer tests** (M1).
|
||||
- **Control-channel messages** (LossReport/Reconfigure/Probe/ClockProbe) assume a data plane; the
|
||||
host (`:881`) + client (`:935`) control tasks must tolerate an input-only session with none —
|
||||
mostly already fine since those are reactive.
|
||||
- **Windows parity:** verify the input-only branch compiles `cfg`-clean where there is no
|
||||
compositor/`virtual_stream`.
|
||||
|
||||
## 11. Validation plan
|
||||
|
||||
**Loopback (no hardware):**
|
||||
- `quic.rs`: `session_flags` encode/decode round-trip; old-peer (no flags byte) → ordinary session;
|
||||
flags coexist with non-default `video_caps`/`audio_channels` (placeholder offsets hold).
|
||||
- Host: an input-only synthetic host+client asserting (a) handshake completes, (b) **no UDP socket
|
||||
binds**, (c) no display/encoder opens, (d) scripted `0xC8`/`0xCC` events inject into a real pad,
|
||||
(e) `0xCA`/`0xCD` feedback returns. Extend the `clients/probe` / `test-loopback.sh` harness.
|
||||
|
||||
**On-box / on-glass (with `steam-controller-deck-support.md` landed):**
|
||||
- Deck (desktop mode) → this host, controller-only: confirm `28DE:1205` opens (not `11FF`); paddles
|
||||
+ both trackpads + gyro reach the host pad; Steam Input on the host re-emits with bindings/glyphs;
|
||||
rumble round-trips; measure input-only latency vs a wired pad and vs the streaming path.
|
||||
- Confirm the host opens **no** virtual output / encoder (logs) and the session does **not** consume
|
||||
a `--max-concurrent` slot (run alongside N video sessions).
|
||||
@@ -26,24 +26,27 @@ remedy, are deferred/accepted with a reason.
|
||||
| #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) |
|
||||
| #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) |
|
||||
| #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP |
|
||||
| #5 | Med | **DEFERRED** — the shared-section SDDL is permissive for a restricted-token UMDF driver; scoping it needs on-box validation to avoid breaking the live-validated gamepad/IDD pipeline |
|
||||
| #5 | Med | **FIXED + on-box validated** (`e59fa60`, 2026-06-29) — section SDDL scoped to `D:(A;;GA;;;SY)(A;;GA;;;LS)`. The "restricted-token" premise was wrong: the WUDFHost token is LocalService, SYSTEM integrity, **zero** restricted SIDs. Validated live on the RTX box — a DualSense+IDD session works (6943 frames, HID round-trip; pf_dualsense + pf_vdisplay WUDFHosts both LocalService) while `OpenFileMapping` from a non-SYSTEM admin session now returns ACCESS_DENIED (was a granted handle under `WD`) |
|
||||
| #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject |
|
||||
| #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up |
|
||||
| #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written |
|
||||
| #9 | Low | **ACCEPTED** — disarm-on-any-attempt IS the documented single-online-guess (prior-fix #2); the delegated-approval flow is structurally immune. Steer hostile LANs to it |
|
||||
| #9 | Low | **FIXED** (`f0574a5`) — a red-team showed the original "delegated approval is the immune fallback" premise was circular with #13. Added a **fingerprint-bound PIN window** (`arm_for`/`pin_for_attempt`): an attempt from any other fingerprint is rejected WITHOUT consuming the window, so an unpaired peer can't pair *or* burn a window armed for a specific device. (Disarm-on-attempt still gives the single-online-guess for the unbound flow; with #13 now flood-resistant, the knock fallback genuinely holds. Web "pair-this-pending-device-with-a-PIN" UX is a follow-up.) |
|
||||
| #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) |
|
||||
| #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) |
|
||||
| #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) |
|
||||
| #13 | Info | **ACCEPTED** — `PENDING_CAP` + LRU + `requested_at` refresh make an actively-retrying device non-evictable |
|
||||
| #13 | Info→Low | **FIXED** (`f0574a5`) — the "actively-retrying device is non-evictable" claim was really a timing race (and the designed flow parks, doesn't re-knock). Added a **per-source-IP cap** (`MAX_PENDING_PER_IP`, QUIC-validated source) so one host can't fill/evict the queue, and eviction now **never drops a live parked knock** — making the delegated-approval path genuinely flood-resistant |
|
||||
| S2 | Low–Med | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open |
|
||||
| S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s |
|
||||
| S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped |
|
||||
| S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint |
|
||||
| S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) |
|
||||
| S7 | Low→Info | **ACKNOWLEDGED** — `rsa 0.9` Marvin has no fixed upstream release; GameStream is off by default and this is a signing (not decryption-oracle) path. Migrate the GameStream identity to Ed25519/ECDSA when feasible |
|
||||
| S7 | Low→Info | **ACCEPTED, rationale corrected + hardened** (`f6c9576`) — the prior "signing, not decryption, so the path isn't exercised" reason was *wrong* (signing runs the same secret-exponent modexp Marvin is about). Accept stands for the right reasons: no decryption/padding oracle; the signed `serversecret` is host-random (not attacker-chosen); signing is operator-PIN-gated; GameStream is off by default and the native plane uses rustls not `rsa`; Moonlight mandates RSA-2048 (no Ed25519 migration possible). Also closed the timing-sample amplifier: sign once per ceremony. No upstream `rsa` fix exists |
|
||||
|
||||
**Net:** 14 of 18 fixed (5 Linux-verified clusters + 4 Windows DACL paths awaiting CI/box); #5
|
||||
deferred pending on-box validation; #9/#13 accepted-with-rationale; S7 acknowledged (no upstream fix).
|
||||
**Net:** 17 of 18 fixed — 5 Linux-verified clusters, 4 Windows DACL paths (#2/#3/#8/#11, awaiting CI
|
||||
compile-confirm), #5 (on-box validated on the RTX box, 2026-06-29), and #9/#13 (closed after a
|
||||
red-team showed their accepts were circular). S7's accept stands but its rationale was corrected and
|
||||
the timing amplifier hardened; only the transitive `rsa 0.9` advisory itself is un-fixable (no
|
||||
upstream release). No finding remains open and actionable.
|
||||
|
||||
## Consolidated overview & top priorities
|
||||
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
# Rich Steam Controller & Steam Deck support
|
||||
|
||||
> **Status:** **M0–M6 GREEN — full pipeline + fallback + conflict gate built (2026-06-29).** Host:
|
||||
> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`),
|
||||
> the protocol carries the rich inputs, the **fallback remap** keeps them from silently dropping, and
|
||||
> the **conflict gate** keeps a virtual Steam pad off a host that already has a physical one. Clients:
|
||||
> the Linux + Windows SDL clients capture + send them; the Decky plugin has the Steam Deck mode +
|
||||
> Disable-Steam-Input UX; the C-ABI has the `TouchpadEx` send path; Apple/Android round-trip the type.
|
||||
>
|
||||
> **⚠ Hardware finding that reframes the ceiling (2026-06-29, §11):** a UHID virtual Deck binds the
|
||||
> kernel `hid-steam` (so the **kernel evdev + SDL-hidapi** consumers see the full surface — grips,
|
||||
> trackpads, IMU) but **Steam Input will NOT manage it** — Steam filters the Deck's controller to USB
|
||||
> **interface 2**, and a single UHID device reports interface `-1`. So the virtual Deck's value is for
|
||||
> **non-Steam / SDL games on Linux**, not Steam Input; the **virtual DualSense** stays the right path
|
||||
> for Steam-Input hosts (Steam recognizes a single-interface DualSense). **Recommendation: do NOT
|
||||
> build M7** (a Windows virtual Deck would hit the same filter with no kernel-evdev fallback — nothing
|
||||
> on Windows would consume it). Remaining is validation only (Moonlight paddle regression; a live
|
||||
> SDL-game consume test).
|
||||
>
|
||||
> **M6 (conflict gate) result — validated on real hardware (a SteamOS Deck + a Bazzite host running
|
||||
> Steam, 2026-06-29):** (a) **Empirical conflict confirmed.** A Deck-as-host already has its physical
|
||||
> `28DE:1205` *and* Steam's `28DE:11FF` XInput output pad live — so a second virtual `28DE` makes Steam
|
||||
> juggle two Decks. (b) **Bind robustness:** the virtual Deck binds `hid-steam` on a *second*
|
||||
> independent kernel (Bazzite 6.17.7, vs the dev box 7.0) and the kernel accepted our serial (the M1
|
||||
> report-id-0 fix). (c) **Criterion-4 (running-Steam recognition) — RESOLVED, negative for Steam
|
||||
> Input (this is the third wall, §2).** Steam's `controller.txt` *enumerates* the virtual Deck
|
||||
> (`Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck"`) but logs **`Interface:
|
||||
> -1`** and never promotes it (no `28DE:11FF` pad, no "Controller connected"). On the same Steam logs
|
||||
> the **physical** Deck is **`Interface: 2`** — a real Deck is a 3-interface USB device (keyboard 0,
|
||||
> mouse 1, **controller 2**), and Steam binds the controller on interface 2. A single UHID device has
|
||||
> no USB interface number → `-1` → Steam skips it. `hid-steam` binds by VID/PID regardless (so the
|
||||
> kernel evdevs + SDL-hidapi path work), **but Steam Input itself will not manage a UHID virtual
|
||||
> Deck.** (The feared `0x83`/`0xA1` attribute probes never fired — it's an interface filter, not a
|
||||
> probe-reject.) See §11 for what this means + the M7 recommendation. **Policy (code):**
|
||||
> `physical_steam_controller_present()` (scans `/sys/bus/hid/devices` for a non-virtual `28DE`) +
|
||||
> `degrade_steam_on_conflict()` in `resolve_gamepad` — a resolved `SteamDeck`/`SteamController` on a
|
||||
> host with a physical Steam controller degrades to DualSense (then the uhid ladder), overridable with
|
||||
> `PUNKTFUNK_STEAM_FORCE=1`. Heuristic hardware-checked: **TRUE on the Deck, FALSE on Bazzite.**
|
||||
> Workspace clippy/fmt/test green.
|
||||
>
|
||||
> **M5 (fallback remap + degrade ladder) result:** new pure, unit-tested `inject/proto/steam_remap.rs`:
|
||||
> (1) **motion rescale** `motion_wire_to_deck` — the wire carries DualSense-convention units (what
|
||||
> every client capture emits), the Deck's `hid-steam` report wants 16 LSB/°·s + 16384 LSB/g, so the
|
||||
> Deck backend now rescales (gyro ×16/20, accel ×16384/10000) — a real **Deck↔Deck gyro/accel
|
||||
> correctness fix**; (2) **`fold_paddles`** + `RemapConfig` (`PUNKTFUNK_STEAM_REMAP=paddles=drop|
|
||||
> stickclicks|shoulders`, default drop) wired into the DualSense + DS4 managers so a client's back
|
||||
> grips aren't silently lost on a PlayStation fallback (those pads have no back-button HID slot; the
|
||||
> uinput Xbox pad already exposes them as `BTN_TRIGGER_HAPPY5-8`). Plus a **runtime degrade ladder**
|
||||
> in `resolve_gamepad`: a UHID backend (DualSense/DS4/SteamDeck) on a host where `/dev/uhid` isn't
|
||||
> writable now falls back to the uinput Xbox 360 pad instead of a dead controller. The throwaway M0/M1
|
||||
> spike is deleted (M2's `#[ignore]`d backend test subsumes it). On-box backend test still green;
|
||||
> workspace clippy/fmt/test green. *Deferred as optional `RemapConfig` growth: gyro→mouse / trackpad→
|
||||
> stick/mouse synthesis on an Xbox target (no IMU/touchpad slot — currently a documented drop).*
|
||||
>
|
||||
> **M4 (complete) result:** *(desktop capture — see the prior entry.)* Plus: **C-ABI** —
|
||||
> `PunktfunkRichInputEx` (size-prefixed superset) + `punktfunk_connection_send_rich_input2` (the
|
||||
> `struct_size` skew-guard precedent; the only way a C client emits `TouchpadEx`); legacy
|
||||
> `PunktfunkRichInput`/`send_rich_input` byte-for-byte; `punktfunk_core.h` regenerated. **Decky** —
|
||||
> a "Steam Deck" gamepad option + an unmissable **Disable-Steam-Input** instruction (shown when
|
||||
> selected) + a best-effort feature-detected programmatic flip in `launchStream` (never throws; the
|
||||
> manual toggle is the source of truth). **Apple/Android parity** — `GamepadType.steamController/
|
||||
> steamDeck` (Swift) + `PREF_STEAMCONTROLLER/STEAMDECK` + the `0x28DE` PIDs in `prefFor` (Kotlin), so
|
||||
> the type round-trips; capture stays out of scope there (iOS GameController won't surface a `28DE`
|
||||
> device; Android has no rich-input plane yet). Rust workspace clippy/fmt/test green; Decky `src/`
|
||||
> typechecks clean; Swift/Kotlin compile on their CI.
|
||||
>
|
||||
> **Pending VALIDATION (construction is done; M7 is NOT recommended — §11):** (1) running-Steam
|
||||
> recognition is **RESOLVED** — Steam won't promote a UHID virtual Deck (interface filter, §11); the
|
||||
> virtual Deck serves non-Steam/SDL games, the virtual DualSense serves Steam Input. (2) A **live
|
||||
> SDL/non-Steam game** consuming the virtual Deck's grips/trackpads (the path that works) — needs a
|
||||
> real Deck/SC client + a Steam-Input-disabled consumer; note the Deck's `/dev/uhid` is root-only
|
||||
> `0600`, so a Deck-as-host needs a udev rule for the input group. (3) The **Moonlight paddle
|
||||
> regression** from the M3 xpad-map change.
|
||||
>
|
||||
> **M4 (desktop client capture) result:** `clients/{linux,windows}/src/gamepad.rs` (the SDL services)
|
||||
> now: set the SDL HIDAPI Steam hints (`SDL_JOYSTICK_HIDAPI_STEAMDECK`/`_STEAM`) so SDL opens Valve
|
||||
> devices directly; detect the Deck/SC by VID/PID (`0x28DE` + `0x1205`/`0x1102`/`0x1142`) →
|
||||
> `GamepadPref::SteamDeck`; map the SDL paddle + Misc1 buttons → the `BTN_PADDLE1..4`/`BTN_MISC1`
|
||||
> wire bits; and route a **second** touchpad → `RichInput::TouchpadEx` (SDL touchpad 0 = left →
|
||||
> surface 1, 1 = right → surface 2, signed coords) while a single touchpad keeps the legacy
|
||||
> `Touchpad`. Held touchpad contacts are now tracked per `(surface,finger)` and lifted on pad
|
||||
> switch/detach. Sensor (gyro/accel) capture was already generic. Linux client builds + clippy clean;
|
||||
> Windows is a near-verbatim mirror (windows CI compiles it). **Caveat:** on a Deck in Game Mode,
|
||||
> Steam Input still holds the device — the user must disable Steam Input for the client (the Decky UX,
|
||||
> next); on a desktop client (or a Deck with Steam Input off) the hints just work.
|
||||
>
|
||||
> **M3 result (protocol / ABI wire, on-box):** strictly additive + forward-compatible (§5).
|
||||
> Core: back-button bits `BTN_PADDLE1..4` + `BTN_MISC1` (in Moonlight's `buttonFlags2<<16`
|
||||
> namespace, so GameStream paddle + native grips share one map); `RichInput::TouchpadEx` (kind
|
||||
> `0x03` — surface 0/1/2, click, signed coords, pressure); `HidOutput::TrackpadHaptic` (kind `0x04`).
|
||||
> ABI: `PUNKTFUNK_GAMEPAD_STEAMDECK=6`/`_STEAMCONTROLLER=5` + the paddle/`RICH_TOUCHPAD_EX`/
|
||||
> `HIDOUT_TRACKPAD_HAPTIC` constants, `from_hid` packs `TrackpadHaptic` into the existing
|
||||
> `which`+`effect[0..6]` (the legacy structs do **not** grow — guarded by `size_of==20/19` asserts);
|
||||
> regenerated `punktfunk_core.h`. Host: `steam_proto::from_gamepad` maps the paddles → the four Deck
|
||||
> grips + QAM; `apply_rich` routes `TouchpadEx` left/right → the matching pad; every DS manager
|
||||
> (DualSense/DS4, Linux + Windows) gained a `TouchpadEx` arm (surface 0/2 → its one touchpad); the
|
||||
> xpad `BUTTON_MAP` finally consumes the GameStream paddle bits (`BTN_TRIGGER_HAPPY5-8`, previously
|
||||
> dropped). Wire round-trips + mapping unit-tested; the on-box backend test now drives the full path
|
||||
> (`from_gamepad` grip + `apply_rich` left-pad) → evdev `BTN_A` + `ABS_HAT0X`. Workspace
|
||||
> clippy/fmt/test green. **Deferred to M4:** the C-ABI `PunktfunkRichInputEx` + `send_rich_input2`
|
||||
> (only the Apple/C *send* path needs it; the host decodes `TouchpadEx` today).
|
||||
>
|
||||
> **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs`
|
||||
> (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend`
|
||||
> (new `SteamDeck` variant + `select`/`handle`/`apply_rich`/`pump`/`heartbeat` arms) and selectable
|
||||
> via `GamepadPref::SteamDeck` (core enum byte 6 + `pick_gamepad` Linux arm; `SteamController` = byte
|
||||
> 5 is reserved, folds to Xbox360 until its backend lands). Two Steam-specific quirks beyond the
|
||||
> DualSense path: (1) **`gamepad_mode` entry** — best-effort `lizard_mode=0` via sysfs + a `b9.6`
|
||||
> creation pulse (`MODE_ENTER` 650 ms) + an **anti-toggle guard** (`MENU_HOLD_CAP` 350 ms) so a long
|
||||
> in-game Start-hold can't flip `gamepad_mode` off; (2) **`UHID_SET_REPORT`** answered `err=0`
|
||||
> (DualSense omits it) + the `0xEB` rumble parsed onto the universal 0xCA plane. An `#[ignore]`d
|
||||
> on-box test (`backend_binds_and_input_flows`) drives the real backend: it binds `hid-steam`
|
||||
> (gamepad + IMU evdevs), enters gamepad mode, `BTN_A` reaches the evdev, and the device tears down
|
||||
> on drop. Workspace clippy/fmt/test green; no generated-header drift (the C-ABI `GamepadPref`
|
||||
> constants are M3).
|
||||
>
|
||||
> **M1 result (byte-exact serializer, on-box):** `inject/proto/steam_proto.rs` now carries the full
|
||||
> Deck contract transcribed verbatim from the kernel `steam_do_deck_input_event` /
|
||||
> `steam_do_deck_sensors_event`: the `u64` button map (bytes 8..16), sticks/triggers/trackpads/IMU
|
||||
> at their exact offsets, `from_gamepad` + `apply_rich` mappers, the rumble-feedback parser
|
||||
> (`0xEB`), and the serial reply (now with the leading report-id byte the kernel strips — fixes the
|
||||
> M0 `XXXXXXXXXX` fallback). The validator pulses the `b9.6` mode-switch to enter `gamepad_mode`
|
||||
> (the parser early-returns under default `lizard_mode` otherwise), holds a known test pattern, and
|
||||
> reads both evdevs via `EVIOCGABS`/`EVIOCGKEY`: **every field matched** — `ABS_X/Y/RX/RY` (incl. the
|
||||
> kernel Y-negation), both triggers, the touched right-pad `HAT1X/Y`, the IMU accel/gyro (with the
|
||||
> `ABS_Z/RZ` negations), and the 6 expected buttons incl. the L4/R5 grips. `byte 8 bit 7 = BTN_A` IS
|
||||
> correct (the M0 "didn't hold" was a flaky single-bit read before `gamepad_mode` was entered). 5
|
||||
> unit tests + workspace clippy/fmt/test green.
|
||||
>
|
||||
> **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike
|
||||
> (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`)
|
||||
> created a UHID `28DE:1205` device with a minimal vendor descriptor (one feature report — the sole
|
||||
> thing `steam_is_valve_interface` checks) and serviced the handshake (the `UHID_SET_REPORT`
|
||||
> answers the DualSense backend omits). `journalctl -k` showed **`hid-steam` binding it** (rebound
|
||||
> off `hid-generic`), `"Steam Controller … connected"`, and the kernel creating **both** evdevs:
|
||||
> `"Steam Deck"` (gamepad) **and** `"Steam Deck Motion Sensors"` (`INPUT_PROP_ACCELEROMETER`).
|
||||
> Outstanding for later: recognition by a **running Steam** client (needs a box with Steam —
|
||||
> untestable here); the `gamepad_mode` entry strategy on a real host (pulse `b9.6` at session start,
|
||||
> or load `hid_steam lizard_mode=0`) is an M2 backend decision.
|
||||
>
|
||||
> **M0 result (box: headless Ubuntu 26.04, kernel 7.0, no Steam):** the spike
|
||||
> (`crates/punktfunk-host/src/bin/steam_uhid_spike.rs` + the keeper `inject/proto/steam_proto.rs`)
|
||||
> created a UHID `28DE:1205` device with a minimal vendor descriptor (one feature report — the sole
|
||||
> thing `steam_is_valve_interface` checks) and serviced the handshake (the `UHID_SET_REPORT`
|
||||
> answers the DualSense backend omits). `journalctl -k` showed **`hid-steam` binding it** (rebound
|
||||
> off `hid-generic`), `"Steam Controller … connected"`, and the kernel creating **both** evdevs:
|
||||
> `"Steam Deck"` (gamepad, `BTN_A` in key caps) **and** `"Steam Deck Motion Sensors"`
|
||||
> (`INPUT_PROP_ACCELEROMETER`, 6 IMU axes). A layout-agnostic mash-probe confirmed the input path:
|
||||
> **23 distinct `BTN_*` codes** (A/B/X/Y, TL/TR, SELECT/START/MODE, THUMBL, all 4 DPAD, grips, back
|
||||
> codes) toggled through `hid-steam → evdev`. ✅ bind ✅ dual evdev incl. IMU ✅ report-parse path.
|
||||
> Outstanding: (4) recognition by a **running Steam** client (needs a box with Steam — untestable
|
||||
> here); and the exact per-bit button/stick/pad/IMU offsets (M1, line-checked vs the lab kernel —
|
||||
> the v6.12-sourced `byte 8 bit 7 = BTN_A` did not hold on 7.0). The serial GET_REPORT reply also
|
||||
> needs its report-number-prefix offset fixed (the kernel used the `XXXXXXXXXX` fallback; non-fatal).
|
||||
|
||||
## 1. Goal + scope
|
||||
|
||||
Carry the **full** Steam Controller / Steam Deck input surface end-to-end and let a remote
|
||||
host present a **real virtual Steam device** that Steam Input and games bind as genuine:
|
||||
|
||||
- the 4 back grips (L4/L5/R4/R5),
|
||||
- both capacitive **trackpads** (the Deck's L/R pads, the SC's dual pads) with touch + click +
|
||||
pressure,
|
||||
- the **IMU** (3-axis gyro + 3-axis accel),
|
||||
- the Steam/quick-access (`…`/QAM) buttons,
|
||||
- haptics/rumble back-channel (Deck rumble motors; SC trackpad voice-coils).
|
||||
|
||||
**Locked decisions (2026-06-29):**
|
||||
|
||||
1. **Full pipeline** — capture on every client + inject on the hosts, not a one-platform demo.
|
||||
2. **Disable-Steam-Input UX** for the Deck-in-Game-Mode capture wall (§6) — we own the
|
||||
instruction and a best-effort programmatic flip; the manual toggle is the source of truth.
|
||||
3. **Max fidelity** — build the greenfield virtual `hid-steam` driver. **Linux UHID first**
|
||||
(validates the contract against open-source `hid-steam.c` + SDL hidapi); **Windows UMDF
|
||||
later**, gated on the Linux result (§8).
|
||||
4. The virtual **DualSense remap is the proven fallback** wherever a virtual Steam device is
|
||||
unavailable or undesired (§7), so Steam-only inputs are *never silently dropped*.
|
||||
|
||||
This is the same architectural bet as the virtual DualSense: the rich semantics
|
||||
(adaptive triggers there; back grips + trackpads + gyro **bindings/glyphs** here) only
|
||||
materialize end-to-end if the **game/Steam sees a real device** and therefore drives them. A
|
||||
generic Xbox pad makes the game take its Xbox code path and the rich surface never exists.
|
||||
|
||||
The unique value of a virtual Steam device is realized **only when the host runs Steam Input**,
|
||||
which re-grabs the `28DE` device and re-emits it as `28DE:11FF` with the user's per-game
|
||||
bindings and correct glyphs. Off-Steam, we fall back to DualSense (§7).
|
||||
|
||||
## 2. The two walls + the honest fidelity ceiling
|
||||
|
||||
There are exactly two hard problems. Everything else is plumbing we have already shipped twice
|
||||
(DualSense, DS4).
|
||||
|
||||
### Wall A — Steam Input capture ownership (client side, solvable via UX)
|
||||
|
||||
On the **client**, enabling SDL's `SDL_JOYSTICK_HIDAPI_STEAMDECK`/`HIDAPI_STEAM` drivers makes
|
||||
SDL open the raw `28DE:1205` and expose paddles + trackpads + gyro as a first-class SDL
|
||||
gamepad (the inputtino/Sunshine path). **But in Deck Gaming Mode, Steam Input grabs the device
|
||||
exclusively** and re-presents it as a virtual XInput pad — so SDL's HIDAPI driver cannot open
|
||||
the raw device and the rich controls silently vanish. **This is inherent**: Steam owns the
|
||||
device. The only escape is the locked Disable-Steam-Input-per-title decision. Outside Game Mode
|
||||
(desktop client, or a Deck used as a streaming *target*), the hints just work.
|
||||
|
||||
### Wall B — virtual-Steam-Controller recognition (host side, art-pushing)
|
||||
|
||||
On the **host**, no public project emulates a virtual `hid-steam` device (inputtino does
|
||||
`hid-playstation`/Xbox/Switch, **not** Steam). Two unknowns stack:
|
||||
|
||||
- **Linux (lower risk):** the kernel bind path is well-mapped from `drivers/hid/hid-steam.c`
|
||||
(open source) — match by VID/PID over `BUS_USB`, answer the probe feature handshake, stream
|
||||
the 64-byte state report. This is provable against open source. *M0 proves it.*
|
||||
- **Windows (higher risk):** Steam's **closed** userspace driver must accept the same contract
|
||||
over a UMDF-presented HID interface, and SDL #12166 shows Steam/SDL **aborts** the controller
|
||||
if its `0x83 GET_ATTRIBUTES_VALUES` / `0xA1 GET_DEVICE_INFO` feature probes fail. Those reply
|
||||
blobs are **not derivable — they must be captured from real hardware**. Deferred to §8.
|
||||
|
||||
### The fidelity ceiling — what is inherent vs solvable
|
||||
|
||||
| Capability | Status |
|
||||
|---|---|
|
||||
| Buttons, dual sticks, analog triggers, dpad | Solvable — maps 1:1 from the existing Xbox-style frame |
|
||||
| Back grips L4/L5/R4/R5 | Solvable on the virtual Steam pad + uinput Xbox (`BTN_TRIGGER_HAPPY*`); **lost on DualSense/DS4 fallback** (no back-button HID slot — remapped or dropped, documented) |
|
||||
| Dual trackpads (touch/click/pressure) | Solvable on the virtual Steam pad; collapses to **one** surface on a DualSense fallback target |
|
||||
| Gyro/accel (IMU) | Solvable — already carried by `RichInput::Motion`; **no IMU on an Xbox fallback** (xpad has none) |
|
||||
| Rich semantics + glyphs in games | **Inherently requires Steam Input running on the host** to re-grab + rebind |
|
||||
| Deck-in-Game-Mode capture | **Inherently requires disabling Steam Input** for the punktfunk title |
|
||||
| Trackpad voice-coil haptics (SC) | Collapsed to the universal rumble plane unless a client renders localized haptics |
|
||||
| Adaptive triggers / lightbar | **N/A** — Steam devices have none |
|
||||
|
||||
## 3. Architecture overview (capture → protocol → inject)
|
||||
|
||||
```
|
||||
CLIENT (SDL3 / GameController / NDK) HOST (punktfunk1.rs PadBackend)
|
||||
┌──────────────────────────────┐ ┌────────────────────────────────────────┐
|
||||
│ buttons+sticks+triggers ────┼── 0xC8 ────────►│ SteamControllerManager (Linux UHID) │
|
||||
│ back grips L4/L5/R4/R5 ────┼── 0xC8 bits ───►│ → serialize_deck_state → /dev/uhid │
|
||||
│ trackpads (2 surfaces) ────┼── 0xCC 0x03 ───►│ → kernel hid-steam binds 28DE:1205 │
|
||||
│ gyro+accel (IMU) ────┼── 0xCC 0x02 ───►│ → gamepad evdev + IMU evdev │
|
||||
│ GamepadPref=SteamDeck ────┼── Hello byte ──►│ → Steam Input re-emits 28DE:11FF │
|
||||
└──────────────────────────────┘ │ │
|
||||
▲ rumble 0xCA / haptic 0xCD 0x04 │ parse_steam_output ◄── UHID_SET_REPORT │
|
||||
└──────────────────────────────────────────┤ 0xEB rumble / 0x8F pad-haptic │
|
||||
│ │
|
||||
│ FALLBACK (target = DualSense/DS4/Xbox): │
|
||||
│ inject/proto/steam_remap.rs │
|
||||
│ gyro→Motion, pads→touchpad/stick/mouse │
|
||||
│ grips→BTN_PADDLE→BTN_TRIGGER_HAPPY │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The **centerpiece** is the virtual `hid-steam` UHID device (§4). Gamepads are **not** on the
|
||||
compositor injector path — they are owned by a per-session `PadBackend` created in
|
||||
`PadBackend::select` and torn down (RAII) when the session input thread exits, identical to the
|
||||
DualSense/DS4 lifecycle. So this drops in with **zero** changes to the injector, capture, or
|
||||
audio planes; only `PadBackend` + `GamepadPref` + the protocol kinds grow.
|
||||
|
||||
The **proven fallback** is the virtual DualSense remap: whenever the resolved backend is not a
|
||||
real Steam device, `steam_remap.rs` folds the Steam-only inputs into whatever pad we do present
|
||||
so nothing is silently lost.
|
||||
|
||||
## 4. The virtual `hid-steam` driver (Linux UHID first)
|
||||
|
||||
The mechanism is the **exact analogue** of the shipped virtual DualSense
|
||||
(`inject/linux/dualsense.rs` + `inject/proto/dualsense_proto.rs`), with three Steam-specific
|
||||
deltas: the **bind identity**, the **feature SET_REPORT handshake** (the DualSense backend only
|
||||
handles GET_REPORT — the Steam path MUST also service SET_REPORT or stall), and the
|
||||
**unnumbered (report-id-0) raw 64-byte framing**.
|
||||
|
||||
### 4.1 Binding mechanism
|
||||
|
||||
`hid-steam` matches **purely by VID/PID over `BUS_USB`**:
|
||||
`HID_USB_DEVICE(0x28DE, 0x1205, STEAM_QUIRK_DECK)`. A `UHID_CREATE2` device with `bus =
|
||||
BUS_USB (0x03)`, `vendor = 0x28DE`, `product = 0x1205` is bound and `steam_probe` runs. `hid-steam`
|
||||
is a **raw-event driver** (`steam_raw_event` returns "handled" and bypasses HID field parsing),
|
||||
so the report descriptor is almost cosmetic — **except** `steam_probe` requires `hid_parse` to
|
||||
succeed *and* a **non-empty FEATURE report list**, so the descriptor MUST declare ≥1 feature
|
||||
report at report id 0.
|
||||
|
||||
**Recommend the Deck (`0x1205`), not the classic SC (`0x1102`), for M0.** The Deck has standard
|
||||
dual sticks + dual analog triggers + 4 back grips + IMU, all of which map cleanly from the
|
||||
existing Xbox-style client frame + `Motion`/`TouchpadEx` planes. The classic SC has **no right
|
||||
stick** (dual trackpads) and trackpad-voice-coil-only haptics — awkward to synthesize. Deck is
|
||||
also what the locked Game-Mode UX targets. SC (`0x1102`, report id 1) is a later identity behind
|
||||
the same manager.
|
||||
|
||||
**Reports are UNNUMBERED (report id 0).** `steam_send_report`/`steam_recv_report` call
|
||||
`hid_hw_raw_request(hdev, 0, …)`; interrupt-in payloads have **no report-id prefix**. So
|
||||
`data[0]` is the protocol constant `0x01`, `data[1] = 0x00`, `data[2] = 0x09`. A *numbered*
|
||||
descriptor would shift the whole frame one byte and `data[0] != 1` would drop every report.
|
||||
|
||||
### 4.2 The feature-report probe contract (the load-bearing delta from DualSense)
|
||||
|
||||
During probe, `steam_register → steam_get_serial()` sends command `0xAE`
|
||||
(`ID_GET_STRING_ATTRIBUTE`) as a **feature SET_REPORT**, then **blocks on a GET_REPORT** for the
|
||||
reply. On UHID these arrive as `UHID_SET_REPORT` (type 13) and `UHID_GET_REPORT` (type 9). The
|
||||
service loop MUST handle **three** event types (vs the DualSense's two):
|
||||
|
||||
| Event | Reply | Notes |
|
||||
|---|---|---|
|
||||
| `UHID_GET_REPORT` (9) | `UHID_GET_REPORT_REPLY` (10) | serial blob `[0xAE, attrib=0x01, len, ascii…]`, or `err=EIO` |
|
||||
| `UHID_SET_REPORT` (13) | `UHID_SET_REPORT_REPLY` (14), `err=0` **always** | **ignore → kernel stalls ~5 s/command**; parse `id u32@[4..8]`, `rnum@[8]`, `rsize u16@[9..11]`, `data@[11..]` |
|
||||
| `UHID_OUTPUT` (6) | parse if present | feedback path |
|
||||
|
||||
Command IDs the device must **ack** (`err=0`) and may parse:
|
||||
|
||||
- `0xAE` `ID_GET_STRING_ATTRIBUTE` (serial) — **NON-FATAL**: the kernel falls back to a fake
|
||||
serial `"XXXXXXXXXX"` and continues either way, so even an EIO reply yields a working device.
|
||||
Answering keeps probe instant.
|
||||
- `0x81` `ID_CLEAR_DIGITAL_MAPPINGS` / `0x8E` `ID_LOAD_DEFAULT_SETTINGS` / `0x87`
|
||||
`ID_SET_SETTINGS_VALUES` (lizard-mode + settings) — **ack err=0, ignore content**. The Deck
|
||||
path **skips** the auto lizard-mode disable at `input_open`, so these only arrive on an
|
||||
options-hold or via Steam userspace — but must be ack'd to avoid the per-command stall.
|
||||
- `0x83` `ID_GET_ATTRIBUTES_VALUES` / `0xA1` `ID_GET_DEVICE_INFO` — **not** issued by the kernel
|
||||
Deck probe, but Steam userspace queries them for full Steam Input fidelity (gyro/back
|
||||
buttons). For M0 bind they are unnecessary; for full fidelity (M3+) answer with
|
||||
real-hardware-captured blobs.
|
||||
|
||||
### 4.3 Input-report layout (Deck `ID_CONTROLLER_DECK_STATE`, msg `0x09`)
|
||||
|
||||
64-byte **unnumbered** report, little-endian. `steam_raw_event` drops anything where
|
||||
`size != 64 || data[0] != 1 || data[1] != 0`, then `switch(data[2])`.
|
||||
|
||||
```
|
||||
[0] 0x01 protocol constant (REQUIRED ==1)
|
||||
[1] 0x00 protocol constant (REQUIRED ==0)
|
||||
[2] 0x09 ID_CONTROLLER_DECK_STATE (0x01 = ID_CONTROLLER_STATE for the SC)
|
||||
[3] len payload length, kernel ignores (set ~0x3C)
|
||||
[4..8] u32 LE frame/sequence counter (monotonic)
|
||||
[8] buttons b8 {A,X,B,Y, L1,R1, L2-full,R2-full}
|
||||
[9] buttons b9 {DPAD_U,DPAD_R,DPAD_L,DPAD_D, view, steam, menu, GRIPL2(L5)}
|
||||
[10] buttons b10 {GRIPR2(R5), lpad_touch, rpad_touch, L3, R3, …}
|
||||
[11..13] b11/b12 reserved/touch + THUMBR alt
|
||||
[13] buttons b13 GRIPL(L4)@bit1, GRIPR(R4)@bit2
|
||||
[14] buttons b14 BTN_BASE (quick-access)@bit2
|
||||
[16..24] s16 x4 LE LEFT pad X/Y, RIGHT pad X/Y → ABS_HAT0X/Y, ABS_HAT1X/Y (res ~1638)
|
||||
[24..36] s16 x6 LE accel X, accel Z(neg), accel Y, gyro X, gyro Z(neg), gyro Y
|
||||
→ IMU ABS_X/Y/Z + ABS_RX/RY/RZ
|
||||
[36..44] s16 x4 LE orientation quaternion (optional)
|
||||
[44..48] u16 x2 LE LEFT trigger, RIGHT trigger → ABS_HAT2Y / ABS_HAT2X
|
||||
[48..56] s16 x4 LE LEFT stick X/Y(neg), RIGHT stick X/Y(neg) → ABS_X/Y, ABS_RX/RY
|
||||
[56..60] u16 x2 LE LEFT/RIGHT pad pressure
|
||||
[60..64] reserved
|
||||
```
|
||||
|
||||
**Neutral state:** sticks/pads/triggers = `0x0000` (signed-centered at 0 — note this differs
|
||||
from the DualSense's `0x80` stick centers); all button bytes 0. On bind the kernel exposes
|
||||
**two** evdevs: a Deck gamepad (`BTN_A`..`BTN_GRIPL/R` + `GRIPL2/R2`, `BTN_BASE`, ABS
|
||||
sticks/triggers/pads) **and** a separate IMU evdev (`INPUT_PROP_ACCELEROMETER`).
|
||||
|
||||
> **The exact per-byte button bit masks and the 0xEB rumble offsets in this table are
|
||||
> summarized from secondary parsing and MUST be confirmed line-by-line against
|
||||
> `steam_do_deck_input_event` / `steam_haptic_rumble` in the lab kernel before trusting input
|
||||
> fidelity.** The backend logs a first-frame layout dump (the DS4 pattern) to catch slips.
|
||||
|
||||
### 4.4 Feedback surface
|
||||
|
||||
Simpler than the DualSense — no lightbar / player LEDs / adaptive triggers. Feedback arrives as
|
||||
a feature **SET_REPORT** (type 13, ack `err=0`), not a `UHID_OUTPUT` interrupt:
|
||||
|
||||
- `0xEB` `ID_TRIGGER_RUMBLE_CMD` — Deck rumble motors; map left/right → `(low, high)` on the
|
||||
existing universal **0xCA** rumble plane (exactly like the DualSense `fb.rumble`).
|
||||
- `0x8F` `ID_TRIGGER_HAPTIC_PULSE` — the SC's two trackpad voice-coils (pad 0=left/1=right/2=both,
|
||||
duration/interval/count). Niche on the Deck; for M0 ack-and-fold into 0xCA (left-pad→low,
|
||||
right-pad→high). For clients with localized haptics, surface as the new `0xCD 0x04`
|
||||
`TrackpadHaptic` (§5).
|
||||
|
||||
No `0xCD` HID-output plane is otherwise needed for the Deck.
|
||||
|
||||
### 4.5 New Linux modules (mirror the DualSense trio)
|
||||
|
||||
- `crates/punktfunk-host/src/inject/proto/steam_proto.rs` — transport-independent contract:
|
||||
`STEAM_VENDOR=0x28DE`; `SteamModel{ Deck=0x1205 rid 9, Controller=0x1102 rid 1 }`; the verbatim
|
||||
`STEAMDECK_RDESC` (≥1 feature report at id 0); `SteamState` superset model (sticks, analog
|
||||
triggers, packed buttons, dpad, `gyro[3]`/`accel[3]`, `back:[bool;4]`, two `SteamPad{active,
|
||||
click, x:i16, y:i16, pressure:u16}` surfaces, steam/quickaccess); `SteamState::from_gamepad`
|
||||
(the XInput mapper + the new paddle/misc wire bits); `serialize_deck_state`/`serialize_sc_state`
|
||||
(byte-exact); `feature_reply(rnum)`; `parse_steam_output(data, &mut SteamFeedback)`. Unit tests
|
||||
for offsets + output parsing, mirroring `dualsense_proto`'s tests.
|
||||
- `crates/punktfunk-host/src/inject/linux/steam_controller.rs` — `/dev/uhid` plumbing +
|
||||
`SteamControllerManager`, byte-identical structure to `dualsense.rs`: `SteamPad::open(index,
|
||||
model)` does `UHID_CREATE2`; `write_state → UHID_INPUT2`; `service()` answers GET_REPORT +
|
||||
SET_REPORT + OUTPUT; `Drop → UHID_DESTROY`; `handle`/`apply_rich`/`pump`/`heartbeat(8 ms)`. The
|
||||
8 ms heartbeat re-emits the last report — a real Deck streams continuously and multi-second
|
||||
silence reads as a disconnect to SDL/Steam.
|
||||
|
||||
Needs `/dev/uhid` writable (the existing `60-punktfunk.rules` udev rule + `input` group, same as
|
||||
DualSense) and `hid-steam` present/loaded (`modprobe hid-steam`; mainline module).
|
||||
|
||||
## 5. Protocol / ABI changes (exact wire/constants)
|
||||
|
||||
Strictly additive and forward-compatible — everything rides existing tags (`0xC8` buttons,
|
||||
`0xCC` rich input, `0xCD` HID-out, the `GamepadPref` Hello/Welcome byte). Unknown kinds/bits drop
|
||||
on old peers exactly as today.
|
||||
|
||||
### 5.1 Back-button bits (`input.rs::gamepad`, ride `0xC8`, no length change)
|
||||
|
||||
We align to Moonlight's `buttonFlags2 << 16` namespace so the GameStream paddle path and the
|
||||
native path share one injector map. The classic-paddle slots are GameStream-aligned; the four
|
||||
Steam grips sit just above the touchpad bit:
|
||||
|
||||
```
|
||||
BTN_PADDLE1 = 0x0001_0000 (R4 / SDL RightPaddle1 / GameStream PADDLE1)
|
||||
BTN_PADDLE2 = 0x0002_0000 (L4 / SDL LeftPaddle1 / GameStream PADDLE2)
|
||||
BTN_PADDLE3 = 0x0004_0000 (R5 / SDL RightPaddle2 / GameStream PADDLE3)
|
||||
BTN_PADDLE4 = 0x0008_0000 (L5 / SDL LeftPaddle2 / GameStream PADDLE4)
|
||||
BTN_TOUCHPAD= 0x0010_0000 (already present, = TOUCHPAD_FLAG << 16)
|
||||
BTN_MISC1 = 0x0020_0000 (Deck '…'/QAM, Share/Capture / GameStream MISC)
|
||||
```
|
||||
|
||||
> **Decision (resolves the placement open-question):** native back buttons **reuse the
|
||||
> GameStream paddle bits** (`0x0001_0000..0x0008_0000`) rather than a separate `0x40_0000+`
|
||||
> range. This unifies the GameStream-paddle and native-grip injector maps onto one table, and
|
||||
> Xbox Elite paddles map for free. Steam L4/L5/R4/R5 ↔ Xbox P1–P4 is a semantic 1:1 for binding
|
||||
> purposes; the device identity carries the glyph distinction.
|
||||
|
||||
### 5.2 `RichInput::TouchpadEx` (kind `0x03`, rides `0xCC`, client→host)
|
||||
|
||||
`0x01 Touchpad` (DualSense single contact) is kept forever. The new superset carries the second
|
||||
pad + click + pressure with **signed** coords matching the real Steam report:
|
||||
|
||||
```
|
||||
[0xCC][0x03][pad u8][surface u8][finger u8][state u8][x i16 LE][y i16 LE][pressure u16 LE] // 12 B
|
||||
surface : 0 = single/DS touchpad, 1 = Steam left pad, 2 = Steam right pad
|
||||
state : bit0 = touch (capacitive contact), bit1 = click (pad depressed)
|
||||
pressure: 0 if the surface has no sensor
|
||||
```
|
||||
|
||||
Decode gated `b.len() >= 12`; unknown kind → `None`. New clients emit `TouchpadEx` for all touch
|
||||
surfaces; the host decodes both `0x01` and `0x03` indefinitely (no flag day). Every existing
|
||||
manager (`dualsense.rs`, `dualshock4.rs`, `dualsense_windows.rs`) gains a `TouchpadEx` arm
|
||||
(treat surface 0/2 → contact, ignore 1) so the new variant compiles everywhere.
|
||||
|
||||
### 5.3 `HidOutput::TrackpadHaptic` (kind `0x04`, rides `0xCD`, host→client)
|
||||
|
||||
```
|
||||
[0xCD][0x04][pad u8][side u8][amplitude u16 LE][period u16 LE µs][count u16 LE] // 10 B
|
||||
```
|
||||
|
||||
Decode gated `b.len() >= 10`. **The ABI `PunktfunkHidOutput` struct is NOT grown** (it has no
|
||||
`struct_size` guard — growing it would overwrite old-built caller buffers): the new kind reuses
|
||||
existing fields — `which = side`, `amplitude/period/count` packed LE into `effect[0..6]` with
|
||||
`effect_len = 6`. Clients without coils drop it (or optionally map to rumble).
|
||||
|
||||
### 5.4 `GamepadPref` source hints
|
||||
|
||||
Trailing fwd-compat Hello/Welcome byte (unknown → `Auto`):
|
||||
|
||||
```
|
||||
5 = SteamController (steam | steamcontroller | sc)
|
||||
6 = SteamDeck (steamdeck | deck | sd)
|
||||
```
|
||||
|
||||
These are **source hints** (what the physical client controller is) so the host prefers the
|
||||
virtual `hid-steam` backend + the right glyph identity; honored only where the backend exists
|
||||
(Linux UHID first), else degraded — the Welcome echoes the **real** resolved backend (honest
|
||||
downgrade). Clients auto-resolve from VID/PID (§6), like DS5→DualSense.
|
||||
|
||||
### 5.5 ABI surface (`abi.rs` + regenerate `include/punktfunk_core.h`)
|
||||
|
||||
- New constants: `PUNKTFUNK_RICH_TOUCHPAD_EX=3`, `PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC=4`,
|
||||
`PUNKTFUNK_GAMEPAD_STEAMCONTROLLER=5`, `PUNKTFUNK_GAMEPAD_STEAMDECK=6`, and
|
||||
`PUNKTFUNK_GAMEPAD_BTN_PADDLE1..4` / `_BTN_MISC1` for embedders building raw `InputEvent`s.
|
||||
- **Do not mutate `PunktfunkRichInput`** (no `struct_size` guard). Add a guarded superset
|
||||
`PunktfunkRichInputEx{ u32 struct_size; u8 kind,pad,finger,active,surface,click; u8 _pad[2];
|
||||
i16 x,y; u16 pressure; i16 gyro[3]; i16 accel[3]; }` + `punktfunk_connection_send_rich_input2`
|
||||
that reads the size prefix first (the `connect_exN`/`config_from_ptr` precedent). Legacy
|
||||
`PunktfunkRichInput` + `send_rich_input` stay byte-for-byte.
|
||||
- `from_hid` gains the `TrackpadHaptic → effect[]`-packing arm; `to_rich` (Ex) gains the
|
||||
`TouchpadEx` arm.
|
||||
- Compile guards: extend the `GamepadPref` lockstep `assert!` block with the two new variants;
|
||||
add `assert!(size_of::<PunktfunkRichInput>()==20)` + `assert!(size_of::<PunktfunkHidOutput>()
|
||||
==19)` so the additive changes can never silently shift the legacy layouts. CI fails on header
|
||||
drift.
|
||||
|
||||
### 5.6 GameStream host-map fix (no protocol change)
|
||||
|
||||
`gamestream/gamepad.rs:91` already computes `buttons = buttonFlags | (buttonFlags2 << 16)`, but
|
||||
the xpad `BUTTON_MAP` drops every bit above `0x8000`, so Moonlight paddle/Share clients are
|
||||
silently no-op'd today. Name the already-decoded bits (`PADDLE1=0x0001_0000 … PADDLE4=
|
||||
0x0008_0000`, `TOUCHPAD=0x0010_0000`, `MISC=0x0020_0000`) and add them to the injector map so
|
||||
the **native** and **GameStream** back-button paths drive one unified output. This is a behavior
|
||||
change for existing Moonlight users — needs a live regression check (§10).
|
||||
|
||||
## 6. Client capture
|
||||
|
||||
### sdl3-rs 0.18.4 API-coverage gate — RESOLVED (was the top client risk)
|
||||
|
||||
Independently verified against docs.rs for `sdl3` 0.18.4 (2026-06-29), so **no raw `sdl3-sys`
|
||||
fallback is needed**: `Button::{LeftPaddle1,RightPaddle1,LeftPaddle2,RightPaddle2,Misc1..6,
|
||||
Touchpad}` exist (confirmed); `Gamepad::touchpads_count()` + `supported_touchpad_fingers()` exist
|
||||
(confirmed); `vendor_id()`/`product_id()` exist (confirmed); `Event::ControllerTouchpad
|
||||
{Down,Motion,Up}` carries the `touchpad` **surface** field the current code discards. **Sensor
|
||||
capture (gyro/accel) is already proven in-tree** — the shipping client enables and reads SDL
|
||||
sensors for the DualSense today (`set_sensors` in `clients/linux/src/gamepad.rs`), so
|
||||
genericizing it past the DualSense gate is a one-line change, not a new dependency. The hint
|
||||
strings `SDL_JOYSTICK_HIDAPI_STEAMDECK` / `SDL_JOYSTICK_HIDAPI_STEAM` live in `sdl3-sys`. There is
|
||||
**no `SDL_GAMEPAD_TYPE_STEAM_DECK`** — detect by VID `0x28DE`.
|
||||
|
||||
### Linux + Windows SDL clients (near-verbatim ports — `clients/{linux,windows}/src/gamepad.rs`)
|
||||
|
||||
1. **Before `sdl3::init()`**, set `SDL_JOYSTICK_HIDAPI_STEAMDECK="1"` + `SDL_JOYSTICK_HIDAPI_STEAM
|
||||
="1"` (the exact `sdl3::hint::set` mechanism already used for `SDL_NO_SIGNAL_HANDLERS`).
|
||||
2. In `pad_info`, override to `GamepadPref::SteamDeck` when `vendor_id()==0x28DE` and
|
||||
`product_id() ∈ {0x1205 Deck, 0x1102 SC wired, 0x1142 SC dongle}`; add a `"Steam Deck"` label.
|
||||
3. Extend `button_bit`: `RightPaddle1→BTN_PADDLE1`, `LeftPaddle1→BTN_PADDLE2`,
|
||||
`RightPaddle2→BTN_PADDLE3`, `LeftPaddle2→BTN_PADDLE4`, `Misc1→BTN_MISC1` (free win for Elite).
|
||||
4. Bind the `touchpad` surface field in the three `ControllerTouchpad*` arms; **branch on
|
||||
`touchpads_count()`** rather than hard-coding 2 — surface 0 → existing `RichInput::Touchpad`,
|
||||
surface ≥1 → `RichInput::TouchpadEx{surface}`.
|
||||
5. Track held contacts keyed by `(surface, finger)` and lift them (`active=false`) in
|
||||
`flush_held` on pad switch/detach (today only the DualSense single surface is implicitly lifted).
|
||||
6. Sensor capture is **already generic** — only the DualSense-only doc comments change.
|
||||
|
||||
### Disable-Steam-Input UX (Decky + docs)
|
||||
|
||||
- **Decky** (`clients/decky/`): a Settings toggle "Capture Steam Deck controls (paddles ·
|
||||
trackpads · gyro)" that selects `gamepad pref=steamdeck`, adds `"steamdeck"` to the option set
|
||||
(TS + `main.py` validation), and renders an **unmissable inline instruction**: gamescope game
|
||||
page → gear → Controller → Steam Input → **Off** for the punktfunk shortcut.
|
||||
- **Best-effort programmatic flip** (`clients/decky/src/steam.ts`):
|
||||
`disableSteamInputForShortcut(appId)` — feature-detected at runtime (guarded exactly like the
|
||||
existing optional `window.DeckyBackend`/`collectionStore` globals), called best-effort inside
|
||||
`ensureShortcut()`, **never** blocking or throwing into `launchStream`. **The manual toggle is
|
||||
the documented source of truth** — there is no confirmed stable SteamClient API and it may
|
||||
regress across Steam updates.
|
||||
|
||||
### Apple / Android — honest no-code-now scope
|
||||
|
||||
- **Apple:** parity only — add a `.steamDeck` enum case (wire byte 6) so the type round-trips;
|
||||
**no capture**. GameController never surfaces a `28DE` HID device as a `GCExtendedGamepad`
|
||||
(Apple has no Steam Input; a raw path would need `IOHIDManager`). Document as blocked.
|
||||
- **Android:** parity only — add `PREF_STEAMDECK` + the `28DE` PIDs to the mapping. Capture of
|
||||
paddles/trackpads/gyro is **out of scope** here: `send_rich_input` is itself still a TODO
|
||||
(`session.rs:13`), and a Deck dongle appears only as a generic gamepad via `InputDevice`.
|
||||
Revisit after the rich-input port lands.
|
||||
|
||||
## 7. Host inject / mapping + host-integration semantics
|
||||
|
||||
### PadBackend wiring
|
||||
|
||||
`enum PadBackend` (`punktfunk1.rs`) gains `#[cfg(target_os="linux")] SteamController
|
||||
(SteamControllerManager)` and `SteamDeck(SteamControllerManager)` — **one manager, a `SteamModel`
|
||||
field** (sticks/descriptor/report differ, logic is shared; the DS4-reuses-DualSense pattern).
|
||||
Wire all five arms (`select`/`handle`/`apply_rich`/`pump`/`heartbeat`). `pick_gamepad` /
|
||||
`resolve_gamepad` gain `GamepadPref::SteamController|SteamDeck if linux` arms; **on Windows and
|
||||
elsewhere they fold to Xbox360** until the UMDF driver lands (§8).
|
||||
|
||||
### Selection / resolution policy (the load-bearing part)
|
||||
|
||||
Unlike the DualSense, a virtual Steam pad only pays off under specific host conditions, so
|
||||
resolution is gated. Highest priority first:
|
||||
|
||||
1. **Explicit client `SteamController`/`SteamDeck` pref** — honored if `/dev/uhid` is writable
|
||||
AND `hid-steam` is loadable; else degrade.
|
||||
2. **`PUNKTFUNK_GAMEPAD=steamdeck|steamcontroller`** host env.
|
||||
3. **Auto** — resolve to a Steam pad **only when the host is running Steam Input** (so the rich
|
||||
semantics are actually consumed). Otherwise Auto prefers **DualSense** (broader non-Steam SDL
|
||||
surface: gyro + a real touchpad) over a Steam pad whose trackpads/grips a non-Steam game
|
||||
won't understand.
|
||||
4. **Degrade ladder** when a requested Steam pad is unavailable: `hid-steam` missing →
|
||||
**DualSense** → **Xbox360**. The Welcome carries the real choice.
|
||||
|
||||
> **SteamOS/Deck-as-host conflict:** a host already running Steam eagerly grabs **any** `28DE`
|
||||
> device, so our virtual pad could be double-handled alongside the operator's physical Deck
|
||||
> controller. **Default policy: gate Steam pads OFF on a SteamOS/gamescope host** unless
|
||||
> explicitly forced (M6 confirms).
|
||||
|
||||
### Fallback remap (`inject/proto/steam_remap.rs`, pure + unit-testable)
|
||||
|
||||
When the resolved backend is DualSense/DS4/Xbox, fold the Steam-only inputs in so nothing drops:
|
||||
|
||||
- **Gyro/accel** → `RichInput::Motion` (native on DualSense/DS4; no-op on Xbox — xpad has no IMU).
|
||||
- **Right trackpad** → DualSense/DS4 touchpad contact (1:1 absolute surface); on an Xbox target,
|
||||
optionally synthesized to the right stick behind a config toggle.
|
||||
- **Left trackpad** → left stick or relative mouse via the existing `InjectorService` pointer
|
||||
plane (config; default mouse, matching SteamOS desktop feel).
|
||||
- **Back buttons L4/L5/R4/R5** → `BTN_PADDLE1..4` → on a uinput Xbox pad, `BTN_TRIGGER_HAPPY1..4`
|
||||
(`0x2c0..0x2c3`, what Steam Input/SDL read as paddles); add the matching `UI_SET_KEYBIT`
|
||||
registrations in `create()`. **DS4/DualSense have no back-button HID slot** — paddles fall back
|
||||
to a configurable default (e.g. L4→L3, R4→R3) or are dropped, **documented, not silent**.
|
||||
- **DS4 100 ms motion-timestamp keepalive** applies whenever motion is forwarded onto a DS4
|
||||
target — keep `apply_rich`/`heartbeat` flowing so the sensor `ts += 188` advances, or games
|
||||
reject motion as stale.
|
||||
|
||||
A `RemapConfig` (env/config driven, e.g. `PUNKTFUNK_STEAM_REMAP=…`) holds the trackpad/back-button
|
||||
policy knobs.
|
||||
|
||||
## 8. Windows UMDF — a later, gated phase
|
||||
|
||||
**Do not start until the Linux UHID device binds.** Linux proves the report descriptor + feature
|
||||
blobs + state layout against open-source `hid-steam.c` + SDL hidapi; Windows then only adds the
|
||||
unknown of **Steam's closed userspace driver** accepting the same contract over UMDF.
|
||||
|
||||
Reuse the **entire** proven `pf-dualsense` UMDF path (the repo already proved a self-signed Rust
|
||||
UMDF HID minidriver loads under Secure Boot ON and is recognized as a genuine controller):
|
||||
|
||||
- Fork `packaging/windows/drivers/pf-dualsense` → `pf-steamdeck`. Keep verbatim the
|
||||
`vhidmini2`-derived WDF scaffolding, the **FORCE_INTEGRITY PE-bit clear** (PE+0x5e), the
|
||||
timer-completes-pended-READ_REPORT pattern, the queue `NumberOfPresentedRequests=u32::MAX` and
|
||||
timer `ExecutionLevel/SynchronizationScope=InheritFromParent + AutomaticSerialization=TRUE`
|
||||
gotchas, the `Global\pfds-shm-<idx>` shared-memory channel, the multi-pad
|
||||
`pszDeviceLocation`/`UmdfHostProcessSharing=ProcessSharingDisabled` plumbing, the
|
||||
`Include=MsHidUmdf.inf`/`WUDFRD.inf` INF stanza, and `SwDeviceCreate` (enumerator `punktfunk`,
|
||||
hardware id `pf_steamdeck`).
|
||||
- Swap identity (VID `0x28DE` / PID `0x1205`), the hid-steam report descriptor, and — the
|
||||
**riskiest, non-derivable** part — the `0x83 GET_ATTRIBUTES_VALUES` + `0xA1 GET_DEVICE_INFO`
|
||||
feature blobs **captured from real hardware** (SDL #12166: Steam/SDL aborts the controller if
|
||||
these probes fail). Add ACK-only SET_FEATURE handlers for `0x81`/`0x87`/`0x8E`.
|
||||
- Host backend: a `SteamDeckWindows` reusing `inject/windows/dualsense_windows.rs` almost
|
||||
wholesale (SwDeviceCreate, map the section, pack the 64-byte state, read the haptic output slot).
|
||||
- Bundle `pf_steamdeck.{inf,cat,dll}` into the existing Inno installer + `install-gamepad-drivers
|
||||
.ps1` pnputil flow, identical to pf-dualsense/DS4/XUSB.
|
||||
|
||||
**NEVER emulate `28DE:11FF`** — that is Steam's own emulated *output* pad, not an input device;
|
||||
emulating it risks a feedback loop where Steam ingests its own output. Watch for: Steam requiring
|
||||
a USB instance path a SwDevice lacks; Steam wanting the sibling emulated keyboard/mouse
|
||||
collections present; VAC/device-trust rejection of a self-signed virtual Steam Controller; and
|
||||
gating the Deck PID (`0x1205`) on Deck hardware (wired-SC `0x1102` may be the safer desktop
|
||||
identity).
|
||||
|
||||
## 9. Milestone plan (M0 is the go/no-go)
|
||||
|
||||
See the structured `milestones`. The shape mirrors the DualSense effort: an **M0 feasibility
|
||||
gate** answers the recognition question before any pipeline is built. M1–M3 are Linux. M4–M5 are
|
||||
clients + protocol. M6 is the SteamOS-host conflict check. M7+ is the deferred Windows UMDF
|
||||
phase, itself re-gated on its own recognition spike.
|
||||
|
||||
## 10. Risks, open questions, validation
|
||||
|
||||
### Validation / test plan
|
||||
|
||||
**Loopback (no hardware):**
|
||||
- Core: `RichInput::TouchpadEx` + `HidOutput::TrackpadHaptic` + `GamepadPref` 5/6 encode/decode
|
||||
round-trips + an old-peer-drops-unknown-kind assertion; the `from_gamepad` paddle/misc mapping;
|
||||
`steam_proto` report-offset + `parse_steam_output` unit tests (mirror `dualsense_proto`); the
|
||||
`steam_remap` fold policy.
|
||||
- `pick_gamepad`/`resolve_gamepad`: client SteamDeck + hid-steam present + Steam → SteamDeck;
|
||||
client SteamDeck + no module → DualSense in the Welcome; Auto + no Steam → DualSense;
|
||||
`PUNKTFUNK_GAMEPAD=steamdeck` forces it; Windows folds to Xbox360. Assert the Welcome echoes
|
||||
the real choice each time.
|
||||
|
||||
**On-box (the box has `hid-steam` mainline + the udev rule):**
|
||||
- **BIND proof (Steam NOT running):** a tiny test main creates the device + heartbeats neutral.
|
||||
Confirm `dmesg` shows `hid-steam … Valve Software Steam Deck Controller`; the sysfs node binds
|
||||
`hid_steam`; a gamepad evdev AND a second IMU evdev appear (`udevadm info` →
|
||||
`ID_INPUT_JOYSTICK=1` + `INPUT_PROP_ACCELEROMETER`).
|
||||
- **RECOGNITION proof:** `sdl2-jstest --list` / an SDL3 app reports GUID `28de:1205` "Steam
|
||||
Deck"; `evtest` shows `BTN_SOUTH` etc.; toggle the A bit and watch the key event.
|
||||
- **STEAM proof (Steam running on the host):** Settings → Controller shows a "Steam Deck
|
||||
Controller"; the kernel evdev disappears (the `client_opened` standoff is expected); bind a
|
||||
back grip to a key in Steam Input and confirm a non-Steam test game sees it.
|
||||
- **RUMBLE proof:** `fftest` / a game triggers `FF_RUMBLE`; confirm a `0xEB` SET_REPORT arrives
|
||||
and our parser emits `(low, high)` on `0xCA` back to the client.
|
||||
- **Cross-machine:** the Linux client (paddles + both pads + gyro) over the LAN → the virtual
|
||||
Deck on the host → Steam re-emits `28DE:11FF` with working bindings + glyphs.
|
||||
- **GameStream regression:** confirm the new `buttonFlags2` consumption doesn't emit spurious
|
||||
back-grip/record events for a stock Moonlight client with a normal pad.
|
||||
|
||||
(Full risks + open questions in the structured fields.)
|
||||
|
||||
|
||||
## 11. The interface-2 ceiling — Steam Input won't manage a UHID virtual Deck (hardware-validated 2026-06-29)
|
||||
|
||||
Validated on a SteamOS Steam Deck (`192.168.1.253`) + a Bazzite host (`192.168.1.41`), both running
|
||||
Steam, with a minimal C UHID probe (`28DE:1205` + the proven descriptor/handshake) run on Bazzite
|
||||
(no physical Steam controller, so a clean test bed).
|
||||
|
||||
**What works.** The kernel `hid-steam` binds the virtual Deck by VID/PID on a second independent
|
||||
kernel (Bazzite 6.17.7) exactly as on the dev box (7.0): it accepts our serial (the M1 report-id-0
|
||||
fix), and creates both the `"Steam Deck"` gamepad evdev and the `"Steam Deck Motion Sensors"` IMU
|
||||
evdev. So **any consumer that reads the kernel evdev or opens the hidraw via SDL's HIDAPI Steam Deck
|
||||
driver sees the full surface** — the four grips (`BTN_GRIPL/R/L2/R2`), both trackpads (`ABS_HAT0/1`),
|
||||
and the IMU.
|
||||
|
||||
**What does NOT work: Steam Input promotion.** Steam's own controller driver *enumerates* the device
|
||||
— `controller.txt` logs `Local Device Found, type: 28de 1205, Product "Punktfunk Steam Deck", path
|
||||
/dev/hidraw1, Interface: -1` — but never promotes it: no `28DE:11FF` virtual XInput pad, no
|
||||
"Controller N connected". On the same Steam logs the **physical** Deck appears as **`Interface: 2`**.
|
||||
A real Steam Deck is a **3-interface USB device** (keyboard = interface 0, mouse = 1, **controller =
|
||||
2**), and Steam binds the controller specifically on interface 2. A single `/dev/uhid` device is not
|
||||
a USB device and has no `bInterfaceNumber`, so Steam reads **`-1`** and filters it out. (Notably the
|
||||
`0x83 GET_ATTRIBUTES` / `0xA1 GET_DEVICE_INFO` probes the prior research feared — SDL #12166 — never
|
||||
fired: this is an interface filter, not an attribute-probe rejection. That blocker, if it exists, is
|
||||
Windows-driver-specific.)
|
||||
|
||||
**Why UHID can't fix it.** UHID creates one HID interface with no USB interface number; you cannot set
|
||||
one, and creating three UHID devices wouldn't help (each is still interface-less / `-1`). Presenting a
|
||||
real multi-interface USB Deck with the controller on interface 2 needs a **USB gadget** (`dummy_hcd` +
|
||||
configfs) or a kernel USB bus driver — a much larger, less portable lift, and contrary to punktfunk's
|
||||
"no kernel bus driver" stance (ViGEm was deliberately removed).
|
||||
|
||||
### Strategic consequences
|
||||
|
||||
- **The virtual Deck's real value is non-Steam / SDL games on Linux** (emulators, native SDL titles,
|
||||
anything reading the kernel evdev or SDL's HIDAPI Steam driver) — there it delivers grips +
|
||||
trackpads + gyro. It does **not** deliver Steam Input glyphs/bindings.
|
||||
- **For Steam-Input hosts, the virtual DualSense is the right path — HARDWARE-VALIDATED (2026-06-29).**
|
||||
A virtual DualSense (UHID, also `Interface: -1`) run on Bazzite while Steam ran was **fully
|
||||
promoted**: `controller.txt` logged `Local Device Found type 054c 0ce6 "DualSense Wireless
|
||||
Controller"`, then **`Controller using HIDAPI driver, vid=0x054c, pid=0x0ce6`** and **loaded
|
||||
`configset_controller_ps5.vdf`** (it even read back our calibration/pairing/firmware feature blobs).
|
||||
So the *same* interface `-1` that the Deck is rejected at is **accepted for the DualSense** — proof
|
||||
the wall is specifically the Deck's multi-interface / interface-2 requirement, *not* a UHID
|
||||
limitation. The DualSense path therefore delivers **real Steam Input** (gyro + touchpad + glyphs +
|
||||
bindings) for a streamed Deck/SC client; the M5 paddle-fold carries the back grips onto standard
|
||||
buttons. This is why the M6 conflict gate degrades to DualSense and `Auto` prefers it. **What the
|
||||
DualSense identity loses vs a real Deck:** Deck glyphs, the *second* trackpad, and the 4 back grips
|
||||
as distinct Steam-Input-bindable paddles (they fold to face/shoulder/stick buttons instead).
|
||||
- **Full Deck-identity Steam Input would need interface 2 → a USB gadget (`dummy_hcd` + configfs HID
|
||||
functions presenting kbd/mouse/controller, controller on interface 2).** Feasible in principle (it
|
||||
gives real interface numbers), but heavy and less portable: `dummy_hcd` is not built on Bazzite, the
|
||||
Deck, or the dev box, so it would have to be built/loaded per-kernel on every Steam host — and an
|
||||
immutable SteamOS/Bazzite host makes that a package-layer + reboot. The marginal gain over the
|
||||
validated DualSense path is Deck glyphs + the 2nd trackpad + native back-paddle bindings.
|
||||
- **M7 (a Windows UMDF virtual Steam Deck) is NOT recommended.** Windows Steam applies the same
|
||||
interface filter, and Windows has **no kernel-`hid-steam` evdev fallback** — Windows games consume
|
||||
XInput / RawInput / Windows.Gaming.Input, none of which a non-promoted virtual Deck feeds. So a
|
||||
Windows virtual Deck would be consumed by *nothing*. The existing Windows **virtual DualSense**
|
||||
already covers the Steam-Input + gyro/touchpad case there.
|
||||
|
||||
### What the M0–M6 work still delivers (not wasted)
|
||||
|
||||
- The **protocol/wire** (back buttons, second trackpad, gyro/accel, trackpad haptics) and the
|
||||
**client capture** (paddles, both trackpads, gyro from a real Deck/SC) are general — they feed the
|
||||
virtual DualSense path (Deck client → Steam-Input host) just as well, with the grips folded in (M5).
|
||||
- The **virtual Deck backend** is the best option for non-Steam Linux games, and the **M5 motion
|
||||
rescale + fallback remap** + the **M6 conflict gate** make the cross-backend behavior correct.
|
||||
- The whole effort proved the greenfield `hid-steam` UHID device is real and kernel-validated on two
|
||||
kernels — the open question was always Steam-userspace promotion, and now it's answered.
|
||||
|
||||
### Remaining validation (no further construction recommended)
|
||||
|
||||
1. A **live SDL/non-Steam game** on a Linux host actually consuming the virtual Deck's grips/trackpads
|
||||
(the path that *does* work) — needs a real Deck/SC client + a Steam-Input-disabled consumer.
|
||||
2. The **Moonlight paddle regression** from the M3 xpad-map change (stock paddle client → host).
|
||||
|
||||
### Gadget PoC — interface 2 is PROVEN on the Deck (2026-06-29)
|
||||
|
||||
SteamOS ships every primitive (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
|
||||
`CONFIG_USB_CONFIGFS_F_HID=y`), so the gadget path is testable on the Deck itself with no
|
||||
module-building. A pure-shell **configfs gadget** (`deck_gadget_up.sh`) stood up a real 3-interface
|
||||
USB Deck on a `dummy_hcd` loopback UDC — keyboard = interface 0, mouse = 1, **controller = interface
|
||||
2** (`STEAMDECK_RDESC`), `28DE:1205`. Result:
|
||||
|
||||
- It enumerates as a real USB device (`lsusb: 28de:1205 Valve Software Steam Deck Controller`) and
|
||||
`hid-steam` binds **all three** interfaces — the controller on `bInterfaceNumber=02`.
|
||||
- **Steam promoted it:** `Local Device Found … Interface: 2 … !! Steam controller device opened for
|
||||
index 14 … Steam Controller reserving XInput slot 1`. *This is the proof: a device on interface 2
|
||||
IS opened + XInput-reserved by Steam, where the interface-`-1` UHID device was filtered out.*
|
||||
- It then failed at the next step — `f_hid` can't serve HID **feature reports** (`hid-steam:
|
||||
steam_send_report: error -32 (ae 16 01)` → serial `XXXXXXXXXX`; Steam: `couldn't get controller
|
||||
details … GetControllerInfo failed … Disconnecting zombie controller`). No gamepad evdev was
|
||||
created either, for the same reason (hid-steam can't complete Deck init without the feature/output
|
||||
channel).
|
||||
|
||||
**Conclusion: the wall is fully characterised and climbable.** Interface 2 is necessary *and*
|
||||
sufficient for Steam to open + XInput-reserve the Deck; the only remaining piece is serving the
|
||||
HID feature/output reports, which `f_hid` can't but **`raw_gadget` can** (userspace handles every
|
||||
control transfer, exactly like the UHID path). Next: a `raw_gadget` userspace emulator of the
|
||||
3-interface Deck (controller on interface 2) that answers the serial/attribute/settings feature
|
||||
reports + streams the 64-byte state report — then re-test hid-steam gamepad evdev + Steam promotion.
|
||||
|
||||
### Gadget path SUCCESS — raw_gadget Deck gets full Steam Input recognition (2026-06-29)
|
||||
|
||||
The `f_hid` zombie was a feature-report problem, and `raw_gadget` (userspace handles every control
|
||||
transfer) solves it. `packaging/linux/steam-deck-gadget/deck_raw_gadget.c` presents the real
|
||||
3-interface Deck (descriptors captured verbatim from a physical Deck, controller on interface 2) and
|
||||
answers the HID feature reports hid-steam/Steam need. Live on the Deck:
|
||||
|
||||
```
|
||||
hid-steam ... Steam Controller 'PFDECK000' connected (serial READ — not XXXXXXXXXX)
|
||||
input: Steam Deck / Steam Deck Motion Sensors (kernel gamepad + IMU evdevs)
|
||||
controller.txt: Interface: 2 ... device opened for index 14 ... reserving XInput slot 1
|
||||
input: Microsoft X-Box 360 pad 1 (Steam Input's XInput output — PROMOTED)
|
||||
```
|
||||
|
||||
Stable (1 connect, 0 disconnects, no zombie). The kernel `"Steam Deck"` evdev is then grabbed by
|
||||
Steam Input, which exposes its own X-Box 360 pad — a real Deck's exact behaviour. **This is the first
|
||||
time a virtual Steam Deck has been fully promoted by Steam Input** (UHID can't; the interface-2 wall
|
||||
is climbed). The hard part — recognition — is done.
|
||||
|
||||
Implementation gotchas (see the packaging README): `struct usb_endpoint_descriptor` is 9 bytes but
|
||||
the wire descriptor needs 7; no-data OUT controls are acked with a zero-length `EP0_READ` not
|
||||
`EP0_WRITE` (else `error -110`); the input streamer must not start until after SET_CONFIGURATION is
|
||||
acked. Scope: SteamOS-host only (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships; a generic
|
||||
Linux host would have to build them).
|
||||
|
||||
**Remaining:** feed real client state through the interface-2 endpoint (the `steam_proto` serializer
|
||||
already produces correct Deck reports — wire it to the gadget's stream), and wrap this as a host
|
||||
gamepad backend (a `raw_gadget` alternative to the UHID `SteamDeckPad`). Then the streamed Deck/SC
|
||||
client reaches the host's games as a true Deck through Steam Input.
|
||||
|
||||
### Input-flow status (2026-06-29) — delivered + format-validated; clean live-read is a backend task
|
||||
|
||||
With the A button held in the streamed report on a `pressa` build, on the Deck:
|
||||
- **Reports are delivered to hid-steam** — the gadget logs `STREAM: first input report delivered
|
||||
(host is polling int IN)`, i.e. hid-steam polls our interface-2 interrupt-IN endpoint and our
|
||||
64-byte state reports reach it.
|
||||
- **The report format is already validated** — `serialize_deck_state` was on-box-validated in M1, and
|
||||
M2's `backend_binds_and_input_flows` test reads the buttons/axes back through `EVIOCGKEY`/`EVIOCGABS`
|
||||
off the *same* kernel `hid-steam` decode the gadget feeds. The gadget changes only the transport.
|
||||
- **The gamepad evdev forms** (`input: Steam Deck` on `5-1:1.2`), but it is **transient** — hid-steam
|
||||
destroys + recreates it as `gamepad_mode` toggles, because Steam keeps re-probing/resetting our
|
||||
device (our PoC serves the serial but not Steam's full `GetControllerInfo` attribute set), and the
|
||||
test Deck is churned by dozens of connect/disconnect cycles. So a *stable* live `EVIOCGKEY` catch of
|
||||
the held A wasn't obtained.
|
||||
|
||||
Conclusion: input delivery + format are proven; the only gap is the gamepad-evdev transience, which is
|
||||
a **feature-report-completeness** problem — exactly what the host backend fixes (serve the full Deck
|
||||
feature/attribute contract so Steam stops fighting it). That's the next step, not more PoC patching.
|
||||
|
||||
### Feature contract hardened — the churn is fixed (2026-06-29)
|
||||
|
||||
The gamepad-evdev churn was Steam re-probing because the gadget served zeros for the HID feature
|
||||
reports Steam's `GetControllerInfo` reads. The real contract was captured from a physical Deck
|
||||
(`packaging/linux/steam-deck-gadget/get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`) and implemented in
|
||||
`steam_gadget.rs::feature_reply`: the **`0x83` GET_ATTRIBUTES_VALUES** blob (`[83,2d, 9×(id,u32-LE)]`
|
||||
— product id `0x1205`, a per-instance unit serial, capability attrs) plus the **`0xAE`** string
|
||||
attributes (serial / board serial) and a settings echo. Result on the Deck: **1 connect / 0
|
||||
disconnect / 1 gamepad evdev** (was constant churn), Steam *activates* the gadget cleanly (no
|
||||
`GetControllerInfo failed`, no zombie) and emits its **X-Box 360 pad 1**. usbmon on the gadget's bus
|
||||
confirms our state reports (pressed button at byte 8) are delivered on the interrupt-IN and consumed
|
||||
by hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode, the input chain is proven end-to-end. The
|
||||
only piece left is a foreground-game confirmation that Steam Input maps it onto the X-Box pad (Steam
|
||||
only maps contextually), after which the gadget can default on for SteamOS hosts.
|
||||
|
||||
### Glass confirmed + default-on for SteamOS (2026-06-29)
|
||||
|
||||
Validated glass-to-glass on the Deck: the gadget shows up as a distinct second Steam controller, a
|
||||
held A snaps the Steam overlay shut as "Resume Game" (so Steam Input receives + acts on the gadget's
|
||||
input), and **a button press registers in a real game** — confirmed in-game. The two-Deck test
|
||||
confound (the Deck has its physical Deck + the virtual one) is a test-rig artifact, not a feature
|
||||
limit: a real non-Deck SteamOS host has only the virtual Deck, and a Deck-as-host degrades `SteamDeck`
|
||||
→ DualSense via the M6 conflict gate before the gadget is ever built. So `gadget_preferred()` now
|
||||
defaults **on for SteamOS** (`/etc/os-release` `ID=steamos`), off elsewhere (UHID stays default),
|
||||
with `PUNKTFUNK_STEAM_GADGET=1`/`0` to force. The virtual Steam Deck — recognized + promoted by Steam
|
||||
Input, churn-free, input flowing to games — is complete.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Plan: production-ready Steam Deck pass-through (client) + shippable virtual Deck on any Linux host
|
||||
|
||||
> **Status (2026-06-29): BUILT — code-complete, all CI checks green on Linux (build · `clippy
|
||||
> -D warnings` · `fmt` · ~270 tests), adversarially reviewed; NOT yet on-glass validated, NOT pushed.**
|
||||
> Implemented in one pass:
|
||||
> - **usbip/`vhci_hcd` transport** (`crates/punktfunk-host/src/inject/linux/steam_usbip.rs`) presenting
|
||||
> a real interface-2 USB Deck, with **in-process vhci attach** (sysfs `OP_REQ_IMPORT` handshake) and a
|
||||
> bounded `usbip`-CLI fallback. Backed by a **vendored, libusb-free trim** of the `usbip` crate
|
||||
> (`crates/punktfunk-host/vendor/usbip-sim/`, MIT, see its NOTICE).
|
||||
> - **Selection ladder** `raw_gadget` (SteamOS) → `usbip` (`vhci_hcd`, universal) → UHID
|
||||
> (`steam_controller.rs::open_transport`); `PUNKTFUNK_STEAM_USBIP=0/1`, `PUNKTFUNK_USBIP_ATTACH=inproc|cli`.
|
||||
> - **Shared Deck device contract** (captured descriptors + `0x83`/`0xAE` `feature_reply` + a
|
||||
> Steam-accepted serial) consolidated into `steam_proto.rs`; the gadget now reuses it.
|
||||
> - **Client leave-shortcuts**: keyboard **Ctrl+Alt+Shift+D** + controller **hold the escape chord
|
||||
> (L1+R1+Start+Select) ≥1.5 s** → disconnect (short press still leaves fullscreen). Steam/QAM are NOT
|
||||
> in the chord. Linux client only for now (windows/apple/android mirror is future work).
|
||||
>
|
||||
> **Decisions taken** (the plan's open questions): vendor-trim the crate (no libusb) ✓; in-process
|
||||
> attach primary + CLI fallback ✓; escalate the existing escape chord (long-hold) ✓; keep BOTH
|
||||
> `raw_gadget` (SteamOS fast-path) and usbip (universal) behind the transport ladder ✓.
|
||||
>
|
||||
> **Remaining = §6 on-glass validation** (Bazzite `192.168.1.41` + Deck `192.168.1.253`): confirm the
|
||||
> in-process usbip attach promotes the Deck in game mode (Steam + QAM reach the game-mode UI), the
|
||||
> raw_gadget path still works on SteamOS (regression), and the leave-shortcuts fire. The dev box has no
|
||||
> Steam + no root, so this could not be run here.
|
||||
>
|
||||
> Companion doc: [`steam-controller-deck-support.md`](steam-controller-deck-support.md)
|
||||
> (the virtual-Deck design + §11 the interface-2 / gadget story). The virtual Steam Deck that Steam
|
||||
> Input promotes is **already built, hardware-validated, and default-on for SteamOS**
|
||||
> (`raw_gadget`/`dummy_hcd`, glass-confirmed in-game on a real Deck).
|
||||
|
||||
## Goal
|
||||
|
||||
When a Steam Deck (or any Valve controller) is the **client**, streaming to a Linux **host** running
|
||||
Steam (SteamOS *or* Bazzite/generic), every Deck control — including the **Steam** logo button and the
|
||||
**QAM "…"** (quick-access) button — passes through and drives the host's game-mode UI, so it feels
|
||||
native. Plus a leave-shortcut (controller + keyboard) since Steam/QAM now pass through.
|
||||
|
||||
## What's already true (do NOT rebuild — verified by investigation `wf_f5e3528b-3ef`)
|
||||
|
||||
The **client capture is already correct**, and the **wire + host mapping already carry Steam + QAM**:
|
||||
|
||||
- SDL3's HIDAPI Steam Deck driver exposes **Steam → `Button::Guide`** (joystick b5) and **QAM "…" →
|
||||
`Button::Misc1`** (joystick b11→`misc1` in `SDL_gamepad_db.h:729`; confirmed in `SDL_gamepad.h`:
|
||||
*"Steam Controller QAM"* = `MISC1`). Paddles → `RightPaddle1/2`,`LeftPaddle1/2`; trackpad clicks →
|
||||
`Touchpad`/`Misc2`.
|
||||
- `clients/linux/src/gamepad.rs:173-201` `button_bit()` already maps `Guide → wire::BTN_GUIDE`,
|
||||
`Misc1 → wire::BTN_MISC1`, all four paddles, touchpad.
|
||||
- Wire buttons are **`u32`** (`crates/punktfunk-core/src/input.rs:54-86`): `BTN_GUIDE=0x0400` (bit 10),
|
||||
`BTN_MISC1=0x0020_0000` (bit 21); free bits = **11, 22-31**. Buttons ride as individual
|
||||
`InputEvent` (0xC8) events (`code`=bit, `x`=1/0); rich input (touchpad/gyro) on `0xCC`.
|
||||
- Host `steam_proto::from_gamepad` (`crates/punktfunk-host/src/inject/proto/steam_proto.rs:179-233`)
|
||||
already maps **`BTN_GUIDE → btn::STEAM`** (line 214) and **`BTN_MISC1 → btn::QAM`** (line 230). The
|
||||
`btn` module: `STEAM = 1<<13`, `QAM = 1<<50`.
|
||||
- **Caveat that matters:** SDL only surfaces Steam/QAM when **Steam Input is NOT grabbing the
|
||||
controller on the client** (else Steam consumes them globally and hands the app its virtual Xbox
|
||||
pad, which lacks Steam/QAM). The fix is *disable Steam Input for the client* — already the Decky
|
||||
plugin's "Disable Steam Input" UX. SDL's HIDAPI Deck driver is on by default on Linux
|
||||
(`SDL_HIDAPI_DEFAULT`); `SDL_JOYSTICK_HIDAPI_STEAMDECK=1` is already set in `gamepad.rs:446-456`.
|
||||
- **Only the Deck host backend carries QAM.** The Xbox/xpad map
|
||||
(`inject/linux/gamepad.rs:80-97`) and DualSense (`dualsense_proto.rs:214`) map Guide→MODE/PS but
|
||||
**drop `BTN_MISC1` (QAM)** — they have no slot for it. So QAM-to-game-mode *requires* the virtual
|
||||
Deck backend (gadget or usbip). This is expected and correct.
|
||||
|
||||
**Net:** for SteamOS hosts the whole feature already works today (client capture → gadget Deck →
|
||||
Steam Input). The remaining work is the *non-SteamOS host* (usbip) + the leave-shortcut + polish.
|
||||
|
||||
## Architecture decision: usbip/`vhci_hcd` is the shippable universal transport
|
||||
|
||||
Presenting a real interface-2 USB Deck on a generic Linux host is the only gap. Decision matrix:
|
||||
|
||||
| Mechanism | Ships? | Why |
|
||||
|---|---|---|
|
||||
| `dummy_hcd` + `raw_gadget` | SteamOS only | In-tree on SteamOS (used + validated). **Not** built on Bazzite/Fedora (`CONFIG_USB_DUMMY_HCD`/`RAW_GADGET` unset); building them needs `kernel-devel` **and** MOK-signing under Secure Boot → **not shippable**. |
|
||||
| **`usbip` + `vhci_hcd`** | **everywhere** | **In-tree + signed** on SteamOS, Bazzite, and ~every distro (it's the standard usbip stack). Loads under Secure Boot, **no module build, no MOK**. A userspace usbip server emulates the Deck; `vhci_hcd` attaches it locally. |
|
||||
|
||||
**Both validated on hardware (2026-06-29):**
|
||||
- `raw_gadget` Deck on a real Steam Deck → Steam promotes it, glass-confirmed in-game.
|
||||
- `usbip` Deck on **Bazzite** → `usbip attach -r 127.0.0.1 -b 0-0-0` → `vhci_hcd` enumerates the
|
||||
3-interface Deck, `hid-steam` binds it, reads the serial, makes the `Steam Deck`/`Motion Sensors`
|
||||
evdevs, **stable (1 connect / 0 disconnect)**, and Steam logs `Interface: 2 … opened for index …
|
||||
reserving XInput slot 1` + emits an X-Box pad. **Identical recognition to the gadget.**
|
||||
|
||||
The working PoC is checked in at `packaging/linux/steam-deck-gadget/usbip-poc/` — the new session
|
||||
should build on it. It uses the `usbip` crate (jiegec/usbip v0.8.0): a custom `UsbInterfaceHandler`
|
||||
(`get_class_specific_descriptor` = the 9-byte HID descriptor; `handle_urb` = GET report-descriptor /
|
||||
HID `GET_REPORT`=`feature_reply` / `SET_REPORT` / interrupt-IN = the 64-byte Deck state), reusing the
|
||||
exact captured descriptors + feature contract from `steam_gadget.rs`.
|
||||
|
||||
## Build steps (ordered)
|
||||
|
||||
### 1. Refactor `steam_gadget.rs` into shared Deck-logic + a transport trait
|
||||
The descriptor set (mouse/kbd/controller report descriptors, the device/config assembly), the
|
||||
`feature_reply` (0x83 attributes + 0xAE serial), and `serialize_deck_state` are **transport-agnostic**
|
||||
and already proven. Extract them into a shared module (e.g. `inject/proto/steam_proto.rs` already holds
|
||||
`serialize_deck_state`/`feature_reply`-equivalents; consolidate the gadget's `feature_reply` +
|
||||
descriptors there or a new `steam_device.rs`). Define:
|
||||
|
||||
```rust
|
||||
/// A virtual Deck transport: feed it the current 64-byte state, drain feedback.
|
||||
trait DeckTransport {
|
||||
fn write_state(&mut self, st: &SteamState);
|
||||
fn service(&mut self) -> Option<(u16, u16)>; // rumble
|
||||
}
|
||||
```
|
||||
Make the existing `raw_gadget` `SteamDeckGadget` implement it (it already has `write_state`/`service`).
|
||||
|
||||
### 2. Add the usbip transport (`SteamDeckUsbip`)
|
||||
- Reuse the PoC's device definition + handler. Drive the interrupt-IN report from the shared
|
||||
`SteamState` (a `Arc<Mutex<[u8;64]>>` the handler reads), updated by `write_state`.
|
||||
- **Dependency decision:** the `usbip` crate hard-depends on `rusb`→`libusb1-sys` (for its *host*
|
||||
mode, which we don't use; it also breaks `musl`). For a clean shippable host, **vendor a trimmed
|
||||
copy** of the crate (keep `lib.rs`, `device.rs`, `interface.rs`, `endpoint.rs`, `setup.rs`,
|
||||
`usbip_protocol.rs`, `util.rs`, `consts.rs`; drop `host.rs`/`cdc.rs`/`hid.rs` + the `rusb`/`nusb`
|
||||
deps) under e.g. `crates/punktfunk-host/vendor/usbip-sim/`, or accept the libusb dep if vendoring
|
||||
is too much churn. Recommendation: vendor-trim (no libusb at runtime).
|
||||
- **Runtime:** the usbip server is tokio-based. Run it on a dedicated runtime/thread (the host already
|
||||
uses tokio behind the `quic` feature). Keep it off the per-frame video path (input only — fine).
|
||||
- **Local attach without the `usbip` CLI (preferred):** don't shell out to `usbip attach`
|
||||
(avoids an external `usbip-utils` runtime dep). Implement the client side in-process: connect to our
|
||||
own server (or better, a `socketpair`/unix socket to avoid a TCP port), do the `OP_REQ_IMPORT`
|
||||
handshake, then write `"<port> <sockfd> <devid> <speed>"` to
|
||||
`/sys/devices/platform/vhci_hcd.0/attach`. (Acceptable fallback for v1: depend on the `usbip` CLI,
|
||||
which is widely packaged, and `Command::new("usbip").args(["attach","-r","127.0.0.1","-b","0-0-0"])`.)
|
||||
- **`ensure_modules`:** `modprobe vhci_hcd` (best-effort) the way the gadget does `dummy_hcd raw_gadget`.
|
||||
|
||||
### 3. Transport selection (in `inject/linux/steam_controller.rs` `ensure()` + `gadget_preferred`)
|
||||
Extend the existing `DeckTransport` enum (currently `Uhid | Gadget`) to `Uhid | Gadget | Usbip` and the
|
||||
selection ladder to: **`raw_gadget` if `/dev/raw-gadget` usable (SteamOS) → else `usbip` if `vhci_hcd`
|
||||
loadable (Bazzite/generic) → else UHID/DualSense.** `gadget_preferred()` currently keys on
|
||||
`ID=steamos`; generalize to "a recognized-by-Steam transport is available" (raw_gadget OR usbip).
|
||||
Keep the M6 conflict gate (`degrade_steam_on_conflict` in `punktfunk1.rs`) ahead of all this — a host
|
||||
with a *physical* Deck still degrades `SteamDeck`→DualSense, so two-Decks never happens in production.
|
||||
|
||||
### 4. Client leave-shortcut (`clients/linux/src/`)
|
||||
Steam/QAM now pass through, so add an explicit disconnect:
|
||||
- **Keyboard:** in `ui_stream.rs:300-310` (next to the `Ctrl+Alt+Shift+Q` capture toggle) add
|
||||
`Ctrl+Alt+Shift+D` → `stop.store(true, …)` (the `stop_h` is already in scope), `Propagation::Stop`.
|
||||
- **Controller:** in `gamepad.rs` (model on `maybe_fire_escape` at `:354-362`, `ESCAPE_CHORD` at `:36`)
|
||||
add a disconnect chord. Recommended: **hold Start+Select+L1+R1 ≥ ~1.5 s** (escalate the existing
|
||||
escape chord — short press leaves fullscreen, long-hold disconnects) OR a dedicated combo. Fire a
|
||||
`disconnect_tx` consumed in `ui_stream.rs` (parallel to the escape future) → set the session `stop`
|
||||
flag (`session.rs:73,212-214`). Do **not** use Steam/QAM in the chord (they're the marquee
|
||||
pass-through buttons). Mirror the same to the other clients (windows/apple/android) later.
|
||||
|
||||
### 5. Polish
|
||||
- **Serial format:** Steam flagged `PFDECK0000` as an "Invalid or missing unit serial number" and
|
||||
substituted `28de-1205-<hash>` (benign, still promoted). Use a serial Steam accepts (a real Deck's
|
||||
is alphanumeric like `FVZZ4200469B`); derive a per-instance valid-looking serial. The `0xAE`
|
||||
attr-1 reply + the `0x83` unit-id attrs (`0x0a`/`0x04`) should be consistent.
|
||||
- Verify the **Decky/client "Disable Steam Input"** path actually frees the Deck controller for SDL on
|
||||
the client (so Steam/QAM reach SDL). This is the one runtime precondition for capture.
|
||||
|
||||
### 6. Validation (glass-to-glass)
|
||||
- **Bazzite host** (`bazzite@192.168.1.41`): run the host with the usbip transport, connect the Linux
|
||||
client (a Deck or a machine with a Valve controller, Steam Input disabled), and confirm in **game
|
||||
mode** that the Steam button opens the Steam menu and the **QAM "…" button opens Quick Access**.
|
||||
- **SteamOS host** (`deck@192.168.1.253`): confirm `raw_gadget` still selected + works (regression).
|
||||
- Confirm the leave-shortcut works from both controller and keyboard while Steam/QAM pass through.
|
||||
|
||||
## Key findings / gotchas (so they aren't rediscovered)
|
||||
|
||||
- **usbip PoC portability:** the glibc build needs `GLIBC_2.34` (Bazzite has 2.42) + libusb (present
|
||||
or vendored) → a dev-box glibc binary runs on Bazzite. `musl` fails (libusb1-sys). The server runs
|
||||
as an unprivileged **user** (TCP 3240); only `modprobe vhci_hcd` + the attach need **root**. A
|
||||
systemd *system* service can't exec from `/home` (perms) — run the server as the user.
|
||||
- **raw_gadget gotchas** (already solved, see `steam-controller-deck-support.md` §11): 7-vs-9-byte
|
||||
endpoint descriptor; no-data OUT controls acked via zero-length `EP0_READ`; no-arg ioctls must pass
|
||||
an explicit `0` (musl); `libc::ioctl` request is `c_ulong`/`c_int` per libc → `as _`.
|
||||
- **Feature contract** is what stops the gamepad-evdev churn (Steam re-probing): serve the captured
|
||||
`0x83 GET_ATTRIBUTES` blob + `0xAE` serial (`packaging/linux/steam-deck-gadget/get_deck_attrs.c`
|
||||
captures them from a physical Deck via hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B). This is
|
||||
already in `steam_gadget.rs::feature_reply` and the usbip PoC.
|
||||
- **Captured descriptors** (verbatim from a physical Deck) live in `steam_gadget.rs` + the usbip PoC:
|
||||
mouse (65B), keyboard (39B), controller (38B, Usage Page `0xFFFF`), endpoints `0x81/0x82/0x83`,
|
||||
controller `bCountryCode 33`.
|
||||
|
||||
## Hardware + recipes
|
||||
|
||||
- **Deck (SteamOS)** `ssh deck@192.168.1.253` — has `dummy_hcd`+`raw_gadget`+`vhci_hcd`+`usbip`; a
|
||||
*physical* Deck controller (so it degrades to DualSense by the M6 gate — for raw_gadget testing
|
||||
there, de-authorize the physical Deck via `/sys/bus/usb/devices/3-3/authorized`). No `gcc`.
|
||||
- **Bazzite** `ssh bazzite@192.168.1.41` — `vhci_hcd`+`usbip` (signed, in-tree), **no** dummy_hcd;
|
||||
Secure Boot **on**; `gcc`+`kernel-devel` present; Steam runs. This is the usbip test bed.
|
||||
- Both need passwordless sudo for driving (`/etc/sudoers.d/zz-punktfunk-poc` — remove when done). SSH
|
||||
via `-o BatchMode=yes`. No `gcc` on the Deck → build static/glibc on the dev box + `scp`.
|
||||
- usbip quick test (Bazzite): `sudo modprobe vhci_hcd; ./usbip-deck-poc pressa & ; sudo usbip attach
|
||||
-r 127.0.0.1 -b 0-0-0` then watch `dmesg` + `~/.local/share/Steam/logs/controller.txt` for
|
||||
`Interface: 2 … reserving XInput slot`.
|
||||
|
||||
## Open decisions for the new session
|
||||
|
||||
1. **Vendor-trim the `usbip` crate (no libusb) vs. accept the `rusb`/libusb dep.** Recommend trim.
|
||||
2. **In-process vhci attach (write the sysfs) vs. shell out to the `usbip` CLI.** Recommend in-process
|
||||
for v1-ship (no external CLI dep); CLI is the quick path to a working build first.
|
||||
3. **Controller leave-chord**: escalate the escape chord (long-hold) vs. a dedicated combo.
|
||||
4. Whether to **unify on usbip everywhere** (it works on SteamOS too) and retire `raw_gadget`, vs.
|
||||
keep `raw_gadget` for SteamOS (already validated). Recommend keep both behind the trait — usbip is
|
||||
the universal fallback, raw_gadget the validated SteamOS fast-path.
|
||||
|
||||
## Commit trail (this work, all on `main`, NOT pushed)
|
||||
|
||||
`faea4f1`…`a33c7d3` (M0–M6) · `b6b6f27` (raw_gadget Deck) · `9e5112b` (feature contract) ·
|
||||
`b3bc313` (host backend) · `8c3188d` (glass-confirmed + default-on SteamOS). The usbip PoC +
|
||||
this plan are the next commits.
|
||||
@@ -28,6 +28,11 @@
|
||||
// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||
#define PUNKTFUNK_HIDOUT_TRIGGER 3
|
||||
|
||||
// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
|
||||
// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||
|
||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||
|
||||
@@ -37,6 +42,12 @@
|
||||
// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
#define PUNKTFUNK_RICH_MOTION 2
|
||||
|
||||
// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
|
||||
// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
|
||||
// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||
// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
#define PUNKTFUNK_RICH_TOUCHPAD_EX 3
|
||||
|
||||
// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host
|
||||
// pick (auto-detect from its running desktop); a concrete value is honored only if that backend
|
||||
// is available on the host right now, else the host falls back to auto-detect. The resolved
|
||||
@@ -82,6 +93,28 @@
|
||||
// hosts); otherwise the host falls back to X-Box 360.
|
||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
||||
|
||||
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
|
||||
|
||||
// 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
|
||||
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||
|
||||
// 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
|
||||
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE1 65536
|
||||
|
||||
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE2 131072
|
||||
|
||||
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE3 262144
|
||||
|
||||
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE4 524288
|
||||
|
||||
#define PUNKTFUNK_GAMEPAD_BTN_MISC1 2097152
|
||||
|
||||
// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||
@@ -139,11 +172,26 @@
|
||||
|
||||
#define PUNKTFUNK_BTN_Y 32768
|
||||
|
||||
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
#define BTN_PADDLE1 65536
|
||||
|
||||
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
#define BTN_PADDLE2 131072
|
||||
|
||||
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
#define BTN_PADDLE3 262144
|
||||
|
||||
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
#define BTN_PADDLE4 524288
|
||||
|
||||
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
|
||||
|
||||
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
#define BTN_MISC1 2097152
|
||||
|
||||
// Axis ids for `InputKind::GamepadAxis`.
|
||||
#define PUNKTFUNK_AXIS_LS_X 0
|
||||
|
||||
@@ -620,6 +668,44 @@ typedef struct {
|
||||
} PunktfunkRichInput;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Forward-compatible superset of [`PunktfunkRichInput`] that can also express the rich Steam
|
||||
// surfaces: a *second* trackpad (`surface`), a distinct `click` vs touch, signed coordinates, and
|
||||
// pressure. Sent via [`punktfunk_connection_send_rich_input2`] — the only way a C client can emit a
|
||||
// `TouchpadEx`. The caller MUST set `struct_size = sizeof(PunktfunkRichInputEx)` (the ABI-skew
|
||||
// guard, like [`PunktfunkConfig`]); the legacy [`PunktfunkRichInput`] +
|
||||
// [`punktfunk_connection_send_rich_input`] stay byte-for-byte for existing callers.
|
||||
typedef struct {
|
||||
// MUST equal `sizeof(PunktfunkRichInputEx)`.
|
||||
uint32_t struct_size;
|
||||
// One of `PUNKTFUNK_RICH_*` (`TOUCHPAD` / `MOTION` / `TOUCHPAD_EX`).
|
||||
uint8_t kind;
|
||||
// Gamepad index.
|
||||
uint8_t pad;
|
||||
// Touchpad/TouchpadEx: contact id.
|
||||
uint8_t finger;
|
||||
// Touchpad/TouchpadEx: 1 = finger down / touching, 0 = lifted.
|
||||
uint8_t active;
|
||||
// TouchpadEx: which surface — 0 = single/DualSense, 1 = Steam left pad, 2 = Steam right pad.
|
||||
uint8_t surface;
|
||||
// TouchpadEx: 1 = the pad is physically clicked (depressed), distinct from a touch contact.
|
||||
uint8_t click;
|
||||
// Reserved for alignment; set to 0.
|
||||
uint8_t _reserved[2];
|
||||
// TouchpadEx: x coordinate — **signed**, centred at 0 (the real Steam report convention). For a
|
||||
// legacy `TOUCHPAD` kind sent through this struct, store the unsigned `0..=65535` value's bits.
|
||||
int16_t x;
|
||||
// TouchpadEx: y coordinate — signed, centred at 0.
|
||||
int16_t y;
|
||||
// TouchpadEx: contact pressure (`0` if the surface has no force sensor).
|
||||
uint16_t pressure;
|
||||
// Motion: gyro (pitch, yaw, roll), raw signed-16.
|
||||
int16_t gyro[3];
|
||||
// Motion: accelerometer (x, y, z), raw signed-16.
|
||||
int16_t accel[3];
|
||||
} PunktfunkRichInputEx;
|
||||
#endif
|
||||
|
||||
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||
@@ -1111,6 +1197,18 @@ PunktfunkStatus punktfunk_connection_send_rich_input(PunktfunkConnection *c,
|
||||
const PunktfunkRichInput *rich);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Send a rich client→host input via the forward-compatible [`PunktfunkRichInputEx`] — the only way
|
||||
// a C client can emit a `TouchpadEx` (a second trackpad / signed coords / pressure). Set
|
||||
// `rich->struct_size = sizeof(PunktfunkRichInputEx)`; a smaller (older-layout) value is rejected.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `rich` is null or points to at least its declared
|
||||
// `struct_size` bytes.
|
||||
PunktfunkStatus punktfunk_connection_send_rich_input2(PunktfunkConnection *c,
|
||||
const PunktfunkRichInputEx *rich);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The currently active session mode — the Welcome's, until an accepted
|
||||
// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Virtual Steam Deck via USB gadget — true Steam Input recognition
|
||||
|
||||
**Proven on a real Steam Deck (SteamOS 3.8.11), 2026-06-29.** A `raw_gadget` userspace emulator of a
|
||||
real 3-interface USB Steam Deck (`28DE:1205`) — mouse = interface 0, keyboard = 1, **controller =
|
||||
interface 2** — bound to a `dummy_hcd` loopback UDC, so the host's own Steam sees a genuine
|
||||
interface-2 Deck and **promotes it through Steam Input** (XInput pad emission, glyphs, bindings).
|
||||
|
||||
## Why this exists (the interface-2 wall)
|
||||
|
||||
A virtual Deck created via **UHID** (the `inject/proto/steam_proto.rs` / `steam_controller.rs` path)
|
||||
binds the kernel `hid-steam` driver, but **Steam Input will not manage it**: Steam filters the Deck's
|
||||
controller to USB **interface 2**, and a UHID device has no USB interface number (`Interface: -1` in
|
||||
Steam's `controller.txt`), so Steam enumerates it but never promotes it. A single-interface DualSense
|
||||
is accepted at `-1` (no ambiguity), but the multi-interface Deck specifically needs interface 2. See
|
||||
`design/steam-controller-deck-support.md` §11.
|
||||
|
||||
A real multi-interface USB device with the controller on interface 2 requires a **USB gadget**.
|
||||
SteamOS ships every piece (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
|
||||
`CONFIG_USB_CONFIGFS_F_HID=y`), so this runs on a Deck with no module-building.
|
||||
|
||||
## What's here
|
||||
|
||||
- **`deck_raw_gadget.c`** — the working emulator. Presents the 3-interface Deck with descriptors
|
||||
captured verbatim from a physical Deck (incl. the real 38-byte controller report descriptor), and
|
||||
— crucially — answers **every** control transfer, including the HID feature reports (`f_hid` can't,
|
||||
so it produced a "zombie controller" in Steam). Streams the 64-byte state report on the interface-2
|
||||
interrupt-IN endpoint. Build static (the Deck has no compiler):
|
||||
```sh
|
||||
gcc -O2 -static -pthread -o deck_raw_gadget deck_raw_gadget.c
|
||||
```
|
||||
Run as root with `dummy_hcd` + `raw_gadget` loaded: `./deck_raw_gadget [seconds]`.
|
||||
- **`configfs_gadget_up.sh` / `_down.sh`** — the simpler **configfs `f_hid`** variant. It proves the
|
||||
structure (interface 2 → `hid-steam` binds → Steam *opens* it + *reserves an XInput slot*) but
|
||||
`f_hid` cannot serve HID feature reports, so Steam can't read controller details and drops it as a
|
||||
zombie. Kept as the minimal reproducer of the interface-2 result.
|
||||
|
||||
## Result (raw_gadget, live)
|
||||
|
||||
```
|
||||
hid-steam ... Steam Controller 'PFDECK000' connected
|
||||
input: Steam Deck / Steam Deck Motion Sensors (kernel gamepad + IMU evdevs)
|
||||
controller.txt: Interface: 2 ... device opened for index 14 ... reserving XInput slot 1
|
||||
input: Microsoft X-Box 360 pad 1 (Steam Input's XInput output — promoted)
|
||||
```
|
||||
Stable (1 connect, 0 disconnects), no zombie. The kernel `"Steam Deck"` evdev is then grabbed by
|
||||
Steam Input, which exposes its own X-Box 360 pad — exactly a real Deck's behaviour.
|
||||
|
||||
## Key implementation gotchas (all real, all cost time)
|
||||
|
||||
- `struct usb_endpoint_descriptor` (ch9.h) is **9 bytes** (audio `bRefresh`/`bSynchAddress`); the wire
|
||||
descriptor needs **7** — use a packed 7-byte struct in the config blob or the kernel mis-parses it.
|
||||
- raw_gadget EP0: a **no-data OUT** control (`SET_CONFIGURATION`, `SET_INTERFACE`, `SET_IDLE`,
|
||||
`SET_PROTOCOL`) is completed with a zero-length **`EP0_READ`**, not `EP0_WRITE` (using write →
|
||||
`EBUSY`/`can't set config error -110`). IN controls (`GET_*`) use `EP0_WRITE`.
|
||||
- Don't start the input streamer until after `SET_CONFIGURATION` is fully acked, or its blocking
|
||||
`EP_WRITE` starves the control path.
|
||||
- `dummy_hcd` + `raw_gadget` must both be loaded and `/dev/raw-gadget` present before launch.
|
||||
|
||||
## Host backend (shipped — default on for SteamOS)
|
||||
|
||||
The C PoC's transport is ported to a Rust host gamepad backend:
|
||||
`crates/punktfunk-host/src/inject/linux/steam_gadget.rs` (`SteamDeckGadget`), driven by the same
|
||||
`steam_proto` serializer as the UHID `SteamDeckPad`. The Steam-Deck manager
|
||||
(`inject/linux/steam_controller.rs`) selects per-pad between **UHID** (universal) and the **USB
|
||||
gadget**: the gadget is the **default on SteamOS hosts** (`gadget_preferred()` → `ID=steamos`;
|
||||
best-effort `modprobe dummy_hcd raw_gadget`, graceful fallback to UHID if `/dev/raw-gadget` is
|
||||
unusable), and off elsewhere where UHID stays the default. `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it.
|
||||
A Deck-as-host with a *physical* Deck never uses it — `resolve_gamepad`'s conflict gate degrades
|
||||
`SteamDeck` → DualSense first.
|
||||
|
||||
The Rust transport is **validated on the Deck** (a static musl test binary that `#[path]`-includes the
|
||||
real module): it enumerates the 3-interface Deck, hid-steam binds it + reads our serial + creates the
|
||||
`Steam Deck` + `Motion Sensors` evdevs — identical to the C PoC. A real USB-stack bug it caught: on
|
||||
musl, `ioctl(fd, RUN)` with no third arg passes a garbage `value`, and raw_gadget's `RUN`/`CONFIGURE`/
|
||||
`EP0_STALL` reject a non-zero `value` with `EINVAL` — so the no-arg ioctls must pass an explicit `0`.
|
||||
|
||||
## Feature contract (hardened — churn fixed)
|
||||
|
||||
Steam's `GetControllerInfo` reads HID **feature reports**; serving the real ones is what stops Steam
|
||||
re-probing (which was destroying + recreating the gamepad evdev — the "churn"). The contract was
|
||||
captured from a physical Deck (`get_deck_attrs.c`, hidraw `HIDIOCGFEATURE`; usbmon truncates to 32B):
|
||||
|
||||
- **`0x83` GET_ATTRIBUTES_VALUES** — `[83, 2d, 9× (attr-id, u32-LE)]`: product id `0x1205`, a unit
|
||||
serial (`0x0a`/`0x04` — we stamp a per-instance value so a gadget never collides with a real Deck),
|
||||
and capability attrs (`0x09=0x2e`, `0x0b=0x0fa0`, `0x02/0x0c/0x0d/0x0e=0`). **This blob is the fix.**
|
||||
- **`0xAE` GET_STRING_ATTRIBUTE** — `[ae, len, attr, ascii]`: serial (attr 1), board serial (attr 0).
|
||||
- Other commands (e.g. `0x87` settings) read back the last write (echo).
|
||||
|
||||
Result on the Deck (`feature_reply` in `steam_gadget.rs`): **1 connect / 0 disconnect / 1 gamepad
|
||||
evdev** (was constant churn), and Steam *activates* the controller cleanly (no `GetControllerInfo
|
||||
failed`, no zombie) and emits its **X-Box 360 pad**. usbmon on the gadget's bus confirms our state
|
||||
reports (with the pressed button at byte 8) are delivered on the interrupt-IN and consumed by
|
||||
hid-steam — so the input transport is proven end-to-end.
|
||||
|
||||
## Remaining
|
||||
|
||||
- **Glass confirmation of the XInput mapping** — Steam Input only maps the gadget's raw input onto its
|
||||
X-Box pad while a game using Steam Input is focused; confirm a button reaches a real game, then
|
||||
default the gadget on for SteamOS hosts (it's strictly better than the non-promoted UHID path).
|
||||
- A `punktfunk-host` build for SteamOS to exercise the integrated path end-to-end with a live client.
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Tear down the PoC virtual Deck gadget.
|
||||
G=/sys/kernel/config/usb_gadget/pfdeck
|
||||
[ -d "$G" ] || { echo "no gadget"; exit 0; }
|
||||
echo "" > "$G/UDC" 2>/dev/null || true
|
||||
for l in "$G"/configs/c.1/hid.usb*; do [ -e "$l" ] && rm -f "$l"; done
|
||||
rmdir "$G"/configs/c.1/strings/0x409 2>/dev/null || true
|
||||
rmdir "$G"/configs/c.1 2>/dev/null || true
|
||||
rmdir "$G"/functions/hid.usb* 2>/dev/null || true
|
||||
rmdir "$G"/strings/0x409 2>/dev/null || true
|
||||
rmdir "$G" 2>/dev/null || true
|
||||
echo "gadget torn down ($(ls /sys/kernel/config/usb_gadget/ 2>/dev/null | wc -l) gadgets remain)"
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# PoC: stand up a REAL 3-interface USB Steam Deck (28DE:1205) on a dummy_hcd loopback UDC, with the
|
||||
# controller on **interface 2** (kbd=0, mouse=1) — the structure Steam's controller driver filters
|
||||
# for. Run as root on the Deck (which ships dummy_hcd + configfs f_hid). Then we check: does hid-steam
|
||||
# bind interface 2, and does the Deck's own Steam promote it (controller.txt "Interface: 2")?
|
||||
set -e
|
||||
G=/sys/kernel/config/usb_gadget/pfdeck
|
||||
|
||||
echo "== modprobe dummy_hcd + libcomposite =="
|
||||
modprobe dummy_hcd
|
||||
modprobe libcomposite
|
||||
UDC=$(ls /sys/class/udc | grep -i dummy | head -1)
|
||||
echo "dummy UDC: ${UDC:-<none found!>}"
|
||||
[ -n "$UDC" ] || { echo "no dummy UDC — abort"; exit 1; }
|
||||
|
||||
# Tear down a prior instance if present.
|
||||
if [ -d "$G" ]; then
|
||||
echo "" > "$G/UDC" 2>/dev/null || true
|
||||
for l in "$G"/configs/c.1/hid.usb*; do [ -e "$l" ] && rm -f "$l"; done
|
||||
rmdir "$G"/configs/c.1/strings/0x409 2>/dev/null || true
|
||||
rmdir "$G"/configs/c.1 2>/dev/null || true
|
||||
rmdir "$G"/functions/hid.usb* 2>/dev/null || true
|
||||
rmdir "$G"/strings/0x409 2>/dev/null || true
|
||||
rmdir "$G" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "== build gadget $G =="
|
||||
mkdir -p "$G"; cd "$G"
|
||||
echo 0x28de > idVendor
|
||||
echo 0x1205 > idProduct
|
||||
echo 0x0110 > bcdDevice
|
||||
echo 0x0200 > bcdUSB
|
||||
mkdir -p strings/0x409
|
||||
echo "Valve Software" > strings/0x409/manufacturer
|
||||
echo "Steam Deck Controller" > strings/0x409/product
|
||||
echo "PFDECK0001" > strings/0x409/serialnumber
|
||||
|
||||
# --- interface 0: boot keyboard ---
|
||||
mkdir -p functions/hid.usb0
|
||||
echo 1 > functions/hid.usb0/protocol
|
||||
echo 1 > functions/hid.usb0/subclass
|
||||
echo 8 > functions/hid.usb0/report_length
|
||||
printf '\x05\x01\x09\x06\xa1\x01\x05\x07\x19\xe0\x29\xe7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x03\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x03\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xc0' > functions/hid.usb0/report_desc
|
||||
|
||||
# --- interface 1: boot mouse ---
|
||||
mkdir -p functions/hid.usb1
|
||||
echo 2 > functions/hid.usb1/protocol
|
||||
echo 1 > functions/hid.usb1/subclass
|
||||
echo 4 > functions/hid.usb1/report_length
|
||||
printf '\x05\x01\x09\x02\xa1\x01\x09\x01\xa1\x00\x05\x09\x19\x01\x29\x03\x15\x00\x25\x01\x95\x03\x75\x01\x81\x02\x95\x01\x75\x05\x81\x03\x05\x01\x09\x30\x09\x31\x15\x81\x25\x7f\x75\x08\x95\x02\x81\x06\xc0\xc0' > functions/hid.usb1/report_desc
|
||||
|
||||
# --- interface 2: the Steam Deck controller (STEAMDECK_RDESC) ---
|
||||
mkdir -p functions/hid.usb2
|
||||
echo 0 > functions/hid.usb2/protocol
|
||||
echo 0 > functions/hid.usb2/subclass
|
||||
echo 64 > functions/hid.usb2/report_length
|
||||
printf '\x06\x00\xff\x09\x01\xa1\x01\x15\x00\x26\xff\x00\x75\x08\x95\x40\x09\x01\x81\x02\x09\x01\x95\x40\xb1\x02\xc0' > functions/hid.usb2/report_desc
|
||||
|
||||
# --- config, link in order so interface numbers are 0,1,2 ---
|
||||
mkdir -p configs/c.1/strings/0x409
|
||||
echo "Punktfunk virtual Deck" > configs/c.1/strings/0x409/configuration
|
||||
echo 250 > configs/c.1/MaxPower
|
||||
ln -s functions/hid.usb0 configs/c.1/
|
||||
ln -s functions/hid.usb1 configs/c.1/
|
||||
ln -s functions/hid.usb2 configs/c.1/
|
||||
|
||||
echo "== bind to $UDC =="
|
||||
echo "$UDC" > UDC
|
||||
sleep 2
|
||||
|
||||
echo ""; echo "===== VERIFY ====="
|
||||
echo "--- /sys hid devices for 28DE (which interface, which driver) ---"
|
||||
for d in /sys/bus/hid/devices/*28DE*; do
|
||||
[ -e "$d" ] || continue
|
||||
rp=$(readlink -f "$d")
|
||||
echo " $(basename "$d"): bInterfaceNumber=$(cat "$rp/../bInterfaceNumber" 2>/dev/null) driver=$(basename "$(readlink -f "$d/driver" 2>/dev/null)")"
|
||||
done
|
||||
echo "--- hidg char devices (controller = hidg for interface 2) ---"; ls -1 /dev/hidg* 2>/dev/null
|
||||
echo "--- kernel log (hid-steam bind + Steam Deck evdev) ---"
|
||||
journalctl -k --since "20 seconds ago" --no-pager 2>/dev/null | grep -iE "steam|28de|1205|hid-generic" | tail -10
|
||||
echo "--- /proc input: Steam Deck evdevs created? ---"
|
||||
grep -c '^N: Name="Steam Deck' /proc/bus/input/devices | sed 's/^/ Steam Deck input nodes: /'
|
||||
echo "--- lsusb ---"; lsusb -d 28de:1205 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Gadget is UP. Feed a neutral controller report with: printf '\\x01\\x00\\x09\\x3c' | dd of=/dev/hidg2 ..."
|
||||
echo "Tear down with: deck_gadget_down.sh"
|
||||
@@ -0,0 +1,260 @@
|
||||
// raw_gadget emulator of a real 3-interface USB Steam Deck (28DE:1205): mouse=iface0, keyboard=iface1,
|
||||
// controller=iface2 (the structure Steam filters for). Unlike f_hid, raw_gadget lets us answer EVERY
|
||||
// control transfer — including the HID feature reports hid-steam/Steam need (the serial etc.) — so the
|
||||
// Deck fully initialises (gamepad evdev) and Steam can read controller details (no "zombie").
|
||||
//
|
||||
// Descriptors captured verbatim from a physical Deck. Build (static, to run on SteamOS):
|
||||
// gcc -O2 -static -o deck_raw_gadget deck_raw_gadget.c -lpthread
|
||||
// Run as root on a host with dummy_hcd loaded: ./deck_raw_gadget [seconds]
|
||||
#include <linux/usb/ch9.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* ---- raw_gadget UAPI (inlined so we don't depend on the header) ---- */
|
||||
#define UDC_NAME_LENGTH_MAX 128
|
||||
struct usb_raw_init { __u8 driver_name[UDC_NAME_LENGTH_MAX]; __u8 device_name[UDC_NAME_LENGTH_MAX]; __u8 speed; };
|
||||
enum usb_raw_event_type { USB_RAW_EVENT_INVALID, USB_RAW_EVENT_CONNECT, USB_RAW_EVENT_CONTROL };
|
||||
struct usb_raw_event { __u32 type; __u32 length; __u8 data[0]; };
|
||||
struct usb_raw_ep_io { __u16 ep; __u16 flags; __u32 length; __u8 data[0]; };
|
||||
#define USB_RAW_EPS_NUM_MAX 30
|
||||
#define USB_RAW_EP_NAME_MAX 16
|
||||
struct usb_raw_ep_caps { __u32 type_control:1, type_iso:1, type_bulk:1, type_int:1, dir_in:1, dir_out:1; };
|
||||
struct usb_raw_ep_limits { __u16 maxpacket_limit; __u16 max_streams; __u32 reserved; };
|
||||
struct usb_raw_ep_info { __u8 name[USB_RAW_EP_NAME_MAX]; __u32 addr; struct usb_raw_ep_caps caps; struct usb_raw_ep_limits limits; };
|
||||
struct usb_raw_eps_info { struct usb_raw_ep_info eps[USB_RAW_EPS_NUM_MAX]; };
|
||||
#define USB_RAW_IOCTL_INIT _IOW('U', 0, struct usb_raw_init)
|
||||
#define USB_RAW_IOCTL_RUN _IO('U', 1)
|
||||
#define USB_RAW_IOCTL_EVENT_FETCH _IOR('U', 2, struct usb_raw_event)
|
||||
#define USB_RAW_IOCTL_EP0_WRITE _IOW('U', 3, struct usb_raw_ep_io)
|
||||
#define USB_RAW_IOCTL_EP0_READ _IOWR('U', 4, struct usb_raw_ep_io)
|
||||
#define USB_RAW_IOCTL_EP_ENABLE _IOW('U', 5, struct usb_endpoint_descriptor)
|
||||
#define USB_RAW_IOCTL_EP_WRITE _IOW('U', 7, struct usb_raw_ep_io)
|
||||
#define USB_RAW_IOCTL_CONFIGURE _IO('U', 9)
|
||||
#define USB_RAW_IOCTL_VBUS_DRAW _IOW('U', 10, __u32)
|
||||
#define USB_RAW_IOCTL_EPS_INFO _IOR('U', 11, struct usb_raw_eps_info)
|
||||
#define USB_RAW_IOCTL_EP0_STALL _IO('U', 12)
|
||||
|
||||
/* ---- captured-from-hardware report descriptors ---- */
|
||||
static const __u8 RDESC_MOUSE[] = {
|
||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
|
||||
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
|
||||
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
|
||||
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0 };
|
||||
static const __u8 RDESC_KBD[] = {
|
||||
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
|
||||
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
|
||||
0x75,0x08,0x95,0x06,0x81,0x00,0xc0 };
|
||||
static const __u8 RDESC_CTRL[] = { // the real Deck controller, interface 2
|
||||
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 (one per interface, points at the report descriptor length) ---- */
|
||||
struct hid_desc { __u8 bLength,bDescriptorType; __u16 bcdHID; __u8 bCountryCode,bNumDescriptors,bReportType; __u16 wReportLength; } __attribute__((packed));
|
||||
/* Exact 7-byte endpoint descriptor — `struct usb_endpoint_descriptor` is 9 bytes (audio bRefresh/
|
||||
bSynchAddress), which would inject 2 garbage bytes per endpoint into the wire config + mis-parse. */
|
||||
struct ep_desc7 { __u8 bLength,bDescriptorType,bEndpointAddress,bmAttributes; __u16 wMaxPacketSize; __u8 bInterval; } __attribute__((packed));
|
||||
|
||||
/* ---- full config descriptor, assembled to mirror the real Deck (3 HID interfaces) ---- */
|
||||
struct config_blob {
|
||||
struct usb_config_descriptor config;
|
||||
struct usb_interface_descriptor i0; struct hid_desc h0; struct ep_desc7 e0;
|
||||
struct usb_interface_descriptor i1; struct hid_desc h1; struct ep_desc7 e1;
|
||||
struct usb_interface_descriptor i2; struct hid_desc h2; struct ep_desc7 e2;
|
||||
} __attribute__((packed));
|
||||
/* Full 9-byte endpoint descriptors, used only for the EP_ENABLE ioctl. */
|
||||
static struct usb_endpoint_descriptor epfull0, epfull1, epfull2;
|
||||
|
||||
static struct usb_device_descriptor dev_desc = {
|
||||
.bLength = USB_DT_DEVICE_SIZE, .bDescriptorType = USB_DT_DEVICE, .bcdUSB = 0x0200,
|
||||
.bDeviceClass = 0, .bDeviceSubClass = 0, .bDeviceProtocol = 0, .bMaxPacketSize0 = 64,
|
||||
.idVendor = 0x28de, .idProduct = 0x1205, .bcdDevice = 0x0300,
|
||||
.iManufacturer = 1, .iProduct = 2, .iSerialNumber = 3, .bNumConfigurations = 1 };
|
||||
|
||||
#define HID_DT 0x21
|
||||
#define HID_RPT_DT 0x22
|
||||
static struct config_blob cfg;
|
||||
static void build_config(void) {
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
cfg.config = (struct usb_config_descriptor){ .bLength = USB_DT_CONFIG_SIZE, .bDescriptorType = USB_DT_CONFIG,
|
||||
.wTotalLength = sizeof(cfg), .bNumInterfaces = 3, .bConfigurationValue = 1, .iConfiguration = 0,
|
||||
.bmAttributes = 0x80, .bMaxPower = 250 };
|
||||
// iface 0: mouse (subclass 0, protocol 2), EP 0x81 IN 8
|
||||
cfg.i0 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
|
||||
.bInterfaceNumber = 0, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 0, .bInterfaceProtocol = 2 };
|
||||
cfg.h0 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 0, 1, HID_RPT_DT, sizeof(RDESC_MOUSE) };
|
||||
cfg.e0 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
|
||||
.bEndpointAddress = 0x81, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 8, .bInterval = 4 };
|
||||
// iface 1: keyboard (subclass 1 boot, protocol 1), EP 0x82 IN 8
|
||||
cfg.i1 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
|
||||
.bInterfaceNumber = 1, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 1, .bInterfaceProtocol = 1 };
|
||||
cfg.h1 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 0, 1, HID_RPT_DT, sizeof(RDESC_KBD) };
|
||||
cfg.e1 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
|
||||
.bEndpointAddress = 0x82, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 8, .bInterval = 4 };
|
||||
// iface 2: the controller (subclass 0, protocol 0), EP 0x83 IN 64
|
||||
cfg.i2 = (struct usb_interface_descriptor){ .bLength = USB_DT_INTERFACE_SIZE, .bDescriptorType = USB_DT_INTERFACE,
|
||||
.bInterfaceNumber = 2, .bNumEndpoints = 1, .bInterfaceClass = 3, .bInterfaceSubClass = 0, .bInterfaceProtocol = 0 };
|
||||
cfg.h2 = (struct hid_desc){ sizeof(struct hid_desc), HID_DT, 0x0110, 33, 1, HID_RPT_DT, sizeof(RDESC_CTRL) };
|
||||
cfg.e2 = (struct ep_desc7){ .bLength = USB_DT_ENDPOINT_SIZE, .bDescriptorType = USB_DT_ENDPOINT,
|
||||
.bEndpointAddress = 0x83, .bmAttributes = USB_ENDPOINT_XFER_INT, .wMaxPacketSize = 64, .bInterval = 4 };
|
||||
// Full 9-byte endpoint descriptors for EP_ENABLE (the ioctl wants struct usb_endpoint_descriptor).
|
||||
#define MKFULL(F,E) do{ memset(&F,0,sizeof F); F.bLength=USB_DT_ENDPOINT_SIZE; F.bDescriptorType=USB_DT_ENDPOINT; \
|
||||
F.bEndpointAddress=E.bEndpointAddress; F.bmAttributes=E.bmAttributes; F.wMaxPacketSize=E.wMaxPacketSize; F.bInterval=E.bInterval; }while(0)
|
||||
MKFULL(epfull0, cfg.e0); MKFULL(epfull1, cfg.e1); MKFULL(epfull2, cfg.e2);
|
||||
}
|
||||
|
||||
static int fd = -1;
|
||||
static int ctrl_ep = -1; // raw handle for the controller IN endpoint
|
||||
static volatile int running = 1;
|
||||
static volatile int configured = 0;
|
||||
static int do_stream = 1; // argv: "nostream" disables the input streamer
|
||||
static int dbg = 1;
|
||||
static __u8 last_feature_cmd = 0; // last SET_REPORT command on iface 2
|
||||
|
||||
static void log_line(const char *s){ fprintf(stderr, "%s\n", s); }
|
||||
|
||||
static int ep0_write(const void *data, int len){
|
||||
char buf[sizeof(struct usb_raw_ep_io)+4096]; struct usb_raw_ep_io *io=(void*)buf;
|
||||
io->ep=0; io->flags=0; io->length=len; if(len) memcpy(io->data,data,len);
|
||||
int r=ioctl(fd, USB_RAW_IOCTL_EP0_WRITE, io);
|
||||
if(r<0){ char m[80]; snprintf(m,sizeof m," !! ep0_write(len=%d) errno=%d", len, errno); log_line(m); }
|
||||
return r;
|
||||
}
|
||||
static int ep0_read(void *data, int len){
|
||||
char buf[sizeof(struct usb_raw_ep_io)+4096]; struct usb_raw_ep_io *io=(void*)buf;
|
||||
io->ep=0; io->flags=0; io->length=len;
|
||||
int r=ioctl(fd, USB_RAW_IOCTL_EP0_READ, io); if(r>=0 && data) memcpy(data, io->data, r<len?r:len); return r;
|
||||
}
|
||||
static void ep0_stall(void){ ioctl(fd, USB_RAW_IOCTL_EP0_STALL); }
|
||||
// Complete a no-data OUT control transfer: the status stage is an IN handled by a zero-length READ.
|
||||
static void ep0_ack(void){ ep0_read(NULL,0); }
|
||||
|
||||
// String descriptors.
|
||||
static int build_string(int idx, __u8 *out){
|
||||
if(idx==0){ out[0]=4; out[1]=USB_DT_STRING; out[2]=0x09; out[3]=0x04; return 4; }
|
||||
const char *s = idx==1?"Valve Software":idx==2?"Steam Deck Controller":idx==3?"PFDECK0001":"";
|
||||
int n=strlen(s); out[0]=2+n*2; out[1]=USB_DT_STRING; for(int i=0;i<n;i++){ out[2+i*2]=s[i]; out[3+i*2]=0; } return 2+n*2;
|
||||
}
|
||||
|
||||
static void enable_endpoints(void){
|
||||
// Enable the 3 interrupt-IN endpoints; remember the controller's handle for streaming.
|
||||
int e0=errno; int h0=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull0); e0=errno;
|
||||
int h1=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull1); int e1=errno;
|
||||
int h2=ioctl(fd, USB_RAW_IOCTL_EP_ENABLE, &epfull2); int e2=errno;
|
||||
ctrl_ep = h2;
|
||||
char m[128]; snprintf(m,sizeof m,"endpoints enabled: mouse=%d(e%d) kbd=%d(e%d) ctrl=%d(e%d)", h0,h0<0?e0:0,h1,h1<0?e1:0,h2,h2<0?e2:0); log_line(m);
|
||||
}
|
||||
|
||||
static void handle_control(struct usb_ctrlrequest *ctrl){
|
||||
int idx = ctrl->wIndex & 0xff;
|
||||
if(dbg){ char m[128]; snprintf(m,sizeof m," CTRL bRT=0x%02x bR=0x%02x wV=0x%04x wI=0x%04x wL=%u",
|
||||
ctrl->bRequestType, ctrl->bRequest, ctrl->wValue, ctrl->wIndex, ctrl->wLength); log_line(m); }
|
||||
// Standard requests
|
||||
if((ctrl->bRequestType & USB_TYPE_MASK) == USB_TYPE_STANDARD){
|
||||
switch(ctrl->bRequest){
|
||||
case USB_REQ_GET_DESCRIPTOR: {
|
||||
int type = ctrl->wValue >> 8, di = ctrl->wValue & 0xff;
|
||||
if(type==USB_DT_DEVICE){ ep0_write(&dev_desc, dev_desc.bLength); return; }
|
||||
if(type==USB_DT_CONFIG){ int l=sizeof(cfg); if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(&cfg, l); return; }
|
||||
if(type==USB_DT_STRING){ __u8 s[260]; int l=build_string(di,s); if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(s,l); return; }
|
||||
if(type==HID_RPT_DT){ // HID report descriptor for the interface in wIndex
|
||||
const __u8 *r; int l;
|
||||
if(idx==0){ r=RDESC_MOUSE; l=sizeof(RDESC_MOUSE);} else if(idx==1){ r=RDESC_KBD; l=sizeof(RDESC_KBD);} else { r=RDESC_CTRL; l=sizeof(RDESC_CTRL);}
|
||||
if(l>ctrl->wLength) l=ctrl->wLength; ep0_write(r,l); return;
|
||||
}
|
||||
if(type==HID_DT){ struct hid_desc *h = idx==0?&cfg.h0:idx==1?&cfg.h1:&cfg.h2; ep0_write(h,h->bLength); return; }
|
||||
ep0_stall(); return;
|
||||
}
|
||||
case USB_REQ_SET_CONFIGURATION: {
|
||||
__u32 power = 0x32; ioctl(fd, USB_RAW_IOCTL_VBUS_DRAW, power);
|
||||
ioctl(fd, USB_RAW_IOCTL_CONFIGURE);
|
||||
enable_endpoints();
|
||||
ep0_ack(); // OUT/no-data: complete via a zero-length read
|
||||
configured = 1; log_line(" SET_CONFIG: done");
|
||||
return;
|
||||
}
|
||||
case USB_REQ_SET_INTERFACE: ep0_ack(); return;
|
||||
case USB_REQ_GET_STATUS: { __u16 s=0; ep0_write(&s,2); return; }
|
||||
default: ep0_stall(); return;
|
||||
}
|
||||
}
|
||||
// HID class requests
|
||||
if((ctrl->bRequestType & USB_TYPE_MASK) == USB_TYPE_CLASS){
|
||||
switch(ctrl->bRequest){
|
||||
case 0x01: { // GET_REPORT
|
||||
// Reply the serial-style feature blob for the controller (iface 2); harmless for others.
|
||||
__u8 rep[64]; memset(rep,0,sizeof rep);
|
||||
// Reply [cmd, len, 0x01, serial...] echoing the last requested command (serial = 0xAE).
|
||||
const char *serial = "PFDECK0001";
|
||||
rep[0]=last_feature_cmd?last_feature_cmd:0xAE; rep[1]=strlen(serial); rep[2]=0x01;
|
||||
memcpy(rep+3, serial, strlen(serial));
|
||||
int l=ctrl->wLength>64?64:ctrl->wLength; ep0_write(rep,l); return;
|
||||
}
|
||||
case 0x09: { // SET_REPORT — read the host's data, remember the command byte
|
||||
__u8 buf[64]; int r=ep0_read(buf,ctrl->wLength>64?64:ctrl->wLength);
|
||||
if(r>0) last_feature_cmd = buf[0]; // unnumbered report: data[0] is the command
|
||||
return; // ep0_read consumes the data stage + acks
|
||||
}
|
||||
case 0x0a: ep0_ack(); return; // SET_IDLE (OUT/no-data)
|
||||
case 0x0b: ep0_ack(); return; // SET_PROTOCOL (OUT/no-data)
|
||||
case 0x03: { __u8 z=0; ep0_write(&z,1); return; } // GET_PROTOCOL
|
||||
default: ep0_stall(); return;
|
||||
}
|
||||
}
|
||||
ep0_stall();
|
||||
}
|
||||
|
||||
static void *stream_thread(void *arg){
|
||||
(void)arg; __u8 rep[64]; __u32 seq=0;
|
||||
while(running){
|
||||
if(configured && ctrl_ep>=0){
|
||||
memset(rep,0,sizeof rep);
|
||||
rep[0]=0x01; rep[1]=0x00; rep[2]=0x09; rep[3]=0x3c; memcpy(rep+4,&seq,4); seq++;
|
||||
char buf[sizeof(struct usb_raw_ep_io)+64]; struct usb_raw_ep_io *io=(void*)buf;
|
||||
io->ep=ctrl_ep; io->flags=0; io->length=64; memcpy(io->data,rep,64);
|
||||
ioctl(fd, USB_RAW_IOCTL_EP_WRITE, io); // blocks until the host polls the int IN ep
|
||||
}
|
||||
struct timespec ts={0, 8*1000*1000}; nanosleep(&ts,NULL);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv){
|
||||
int seconds = argc>1?atoi(argv[1]):120;
|
||||
for(int i=1;i<argc;i++){ if(!strcmp(argv[i],"nostream")) do_stream=0; }
|
||||
build_config();
|
||||
fd = open("/dev/raw-gadget", O_RDWR);
|
||||
if(fd<0){ perror("open /dev/raw-gadget"); return 1; }
|
||||
struct usb_raw_init init; memset(&init,0,sizeof init);
|
||||
strcpy((char*)init.driver_name, "dummy_udc");
|
||||
strcpy((char*)init.device_name, "dummy_udc.0");
|
||||
init.speed = USB_SPEED_HIGH;
|
||||
if(ioctl(fd, USB_RAW_IOCTL_INIT, &init)){ perror("INIT"); return 1; }
|
||||
if(ioctl(fd, USB_RAW_IOCTL_RUN)){ perror("RUN"); return 1; }
|
||||
log_line("raw_gadget Deck running (28DE:1205, controller on interface 2)");
|
||||
|
||||
pthread_t th; if(do_stream) pthread_create(&th,NULL,stream_thread,NULL);
|
||||
|
||||
struct timespec start; clock_gettime(CLOCK_MONOTONIC,&start);
|
||||
char ebuf[sizeof(struct usb_raw_event)+256];
|
||||
struct usb_raw_event *ev=(void*)ebuf;
|
||||
while(running){
|
||||
struct timespec n; clock_gettime(CLOCK_MONOTONIC,&n);
|
||||
if(n.tv_sec-start.tv_sec>=seconds) break;
|
||||
ev->type=0; ev->length=sizeof(struct usb_ctrlrequest);
|
||||
if(ioctl(fd, USB_RAW_IOCTL_EVENT_FETCH, ev)<0){ if(running) perror("EVENT_FETCH"); break; }
|
||||
if(ev->type==USB_RAW_EVENT_CONNECT){ log_line("CONNECT"); }
|
||||
else if(ev->type==USB_RAW_EVENT_CONTROL){ handle_control((struct usb_ctrlrequest*)ev->data); }
|
||||
}
|
||||
running=0; if(do_stream) pthread_join(th,NULL);
|
||||
log_line("exiting");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Query a physical Steam Deck's feature reports (SET command then GET response) over hidraw to get
|
||||
// the FULL blobs (usbmon truncates to 32 bytes). Steam feature reports are unnumbered 64-byte; the
|
||||
// hidraw buffer prefixes a report-id byte (0).
|
||||
#include <linux/hidraw.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
static void dump(const char *name, unsigned char *b, int n) {
|
||||
printf("%s rc=%d:", name, n);
|
||||
for (int i = 0; i < (n > 0 ? n : 0); i++) printf(" %02x", b[i]);
|
||||
printf("\n");
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 2) { printf("usage: %s /dev/hidrawN\n", argv[0]); return 1; }
|
||||
int fd = open(argv[1], O_RDWR);
|
||||
if (fd < 0) { perror("open"); return 1; }
|
||||
// SET [reportid=0, cmd, len, attr, ...] then GET the response.
|
||||
unsigned char queries[][4] = {
|
||||
{0x83, 0x00, 0x00, 0x00}, // GET_ATTRIBUTES_VALUES
|
||||
{0xae, 0x16, 0x01, 0x00}, // GET_STRING_ATTRIBUTE, serial (attr 1)
|
||||
{0xae, 0x16, 0x00, 0x00}, // GET_STRING_ATTRIBUTE, attr 0
|
||||
{0xae, 0x16, 0x02, 0x00}, // attr 2 (board serial?)
|
||||
};
|
||||
for (int q = 0; q < 4; q++) {
|
||||
unsigned char set[65] = {0};
|
||||
set[0] = 0; // report id 0
|
||||
memcpy(set + 1, queries[q], 4);
|
||||
int sr = ioctl(fd, HIDIOCSFEATURE(65), set);
|
||||
usleep(3000);
|
||||
unsigned char get[65] = {0};
|
||||
get[0] = 0;
|
||||
int gr = ioctl(fd, HIDIOCGFEATURE(65), get);
|
||||
printf("=== query cmd=%02x attr=%02x (SET rc=%d) ===\n", queries[q][0], queries[q][2], sr);
|
||||
dump(" GET", get, gr);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "usbip-deck-poc"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "usbip-deck-poc"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
usbip = "*"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
env_logger = "0.11"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
@@ -0,0 +1,25 @@
|
||||
# usbip Deck PoC — the shippable virtual Deck for non-SteamOS Linux hosts
|
||||
|
||||
Presents a real 3-interface USB Steam Deck (`28DE:1205`, controller on **interface 2**) over the
|
||||
usbip protocol via the `usbip` crate, so `vhci_hcd` can attach it **locally** — the Secure-Boot-clean,
|
||||
universal alternative to `dummy_hcd`+`raw_gadget` (which Bazzite/Fedora don't ship). Reuses the exact
|
||||
captured descriptors + feature contract from `crates/punktfunk-host/src/inject/linux/steam_gadget.rs`.
|
||||
|
||||
**Validated live on Bazzite (2026-06-29):** `vhci_hcd` enumerates it, `hid-steam` binds it + reads the
|
||||
serial + makes the `Steam Deck` evdevs, stable (1 connect / 0 disconnect), and **Steam promotes it**
|
||||
(`Interface: 2 … reserving XInput slot 1`, X-Box pad emitted) — identical to the gadget on SteamOS.
|
||||
|
||||
```sh
|
||||
cargo build --release # glibc; needs GLIBC_2.34 (Bazzite has 2.42), libusb present/vendored
|
||||
# on the host (root for the last two):
|
||||
sudo modprobe vhci_hcd
|
||||
./usbip-deck-poc pressa & # the usbip server (runs as a normal user, TCP 127.0.0.1:3240)
|
||||
sudo usbip attach -r 127.0.0.1 -b 0-0-0
|
||||
```
|
||||
|
||||
See `design/steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to
|
||||
drop the `rusb`/libusb dep; in-process `vhci_hcd` attach to avoid the `usbip` CLI; transport-select
|
||||
`raw_gadget`→`usbip`→UHID). `usbip` crate API: a custom `UsbInterfaceHandler` —
|
||||
`get_class_specific_descriptor()` = the 9-byte HID descriptor; `handle_urb()` dispatches EP0
|
||||
`GET_DESCRIPTOR`(report) / HID `GET_REPORT`(=`feature_reply`) / `SET_REPORT`, and returns the 64-byte
|
||||
state report on the interrupt-IN endpoint.
|
||||
@@ -0,0 +1,179 @@
|
||||
// usbip Deck PoC: present a real 3-interface USB Steam Deck (28DE:1205, controller on interface 2)
|
||||
// over the usbip protocol via the `usbip` crate, so vhci_hcd can attach it LOCALLY — the Secure-Boot-
|
||||
// clean, universal alternative to dummy_hcd+raw_gadget. Validates that Steam recognizes a usbip-
|
||||
// presented interface-2 Deck (on Bazzite, where dummy_hcd isn't available).
|
||||
//
|
||||
// Run as root with vhci_hcd loaded, then locally: usbip attach -r 127.0.0.1 -b 0-0-0
|
||||
use std::any::Any;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use usbip::{Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer};
|
||||
|
||||
// ---- captured-from-hardware report descriptors (a real Steam Deck) ----
|
||||
const RDESC_MOUSE: &[u8] = &[
|
||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
|
||||
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
|
||||
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
|
||||
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0];
|
||||
const RDESC_KBD: &[u8] = &[
|
||||
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
|
||||
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
|
||||
0x75,0x08,0x95,0x06,0x81,0x00,0xc0];
|
||||
const RDESC_CTRL: &[u8] = &[ // the real Deck controller, interface 2 (Usage Page 0xFFFF)
|
||||
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];
|
||||
|
||||
fn hid_desc(report_len: usize, country: u8) -> Vec<u8> {
|
||||
let l = report_len as u16;
|
||||
vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8]
|
||||
}
|
||||
|
||||
/// Captured-from-hardware feature replies (the contract Steam's GetControllerInfo reads).
|
||||
fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> Vec<u8> {
|
||||
let cmd = last_set.first().copied().unwrap_or(0xAE);
|
||||
let mut r = vec![0u8; 64];
|
||||
match cmd {
|
||||
0x83 => {
|
||||
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 => {
|
||||
let attr = last_set.get(2).copied().unwrap_or(0x01);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().clamp(1, 20);
|
||||
r[0] = 0xAE;
|
||||
r[1] = len as u8;
|
||||
r[2] = attr;
|
||||
r[3..3 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
_ => {
|
||||
let n = last_set.len().min(64);
|
||||
r[..n].copy_from_slice(&last_set[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The Deck controller interface (vendor HID): answers feature reports + streams the 64-byte state.
|
||||
#[derive(Debug)]
|
||||
struct ControllerHandler {
|
||||
report_desc: Vec<u8>,
|
||||
last_set: Vec<u8>,
|
||||
seq: u32,
|
||||
press_a: bool,
|
||||
}
|
||||
impl UsbInterfaceHandler for ControllerHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(self.report_desc.len(), 33)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() {
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
(0x81, 0x06) if (setup.value >> 8) == 0x22 => self.report_desc.clone(), // GET report descriptor
|
||||
(0xA1, 0x01) => feature_reply(&self.last_set, "PFDECK0000", 0x5046_0000), // HID GET_REPORT (feature)
|
||||
(0x21, 0x09) => {
|
||||
self.last_set = req.to_vec();
|
||||
vec![]
|
||||
} // HID SET_REPORT
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
||||
_ => vec![],
|
||||
})
|
||||
} else if let Direction::In = ep.direction() {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = vec![0u8; 64];
|
||||
r[0] = 0x01;
|
||||
r[2] = 0x09;
|
||||
r[3] = 0x3c;
|
||||
r[4..8].copy_from_slice(&self.seq.to_le_bytes());
|
||||
if self.press_a {
|
||||
r[8] = 0x80; // btn::A
|
||||
}
|
||||
Ok(r)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal HID interface (mouse/keyboard) — serves its report descriptor, sends nothing.
|
||||
#[derive(Debug)]
|
||||
struct IdleHidHandler {
|
||||
report_desc: Vec<u8>,
|
||||
}
|
||||
impl UsbInterfaceHandler for IdleHidHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(self.report_desc.len(), 0)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_i: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_l: u32,
|
||||
setup: SetupPacket,
|
||||
_req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 {
|
||||
Ok(self.report_desc.clone())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn handler(h: impl UsbInterfaceHandler + Send + 'static) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
|
||||
Arc::new(Mutex::new(Box::new(h)))
|
||||
}
|
||||
fn ep(addr: u8, mps: u16) -> UsbEndpoint {
|
||||
UsbEndpoint { address: addr, attributes: 0x03, max_packet_size: mps, interval: 4 }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
let press_a = std::env::args().any(|a| a == "pressa");
|
||||
|
||||
let mut dev = UsbDevice::new(0);
|
||||
dev.vendor_id = 0x28DE;
|
||||
dev.product_id = 0x1205;
|
||||
dev.set_manufacturer_name("Valve Software");
|
||||
dev.set_product_name("Steam Deck Controller");
|
||||
dev.set_serial_number("PFDECK0000");
|
||||
|
||||
// interface 0: mouse, interface 1: keyboard, interface 2: the controller.
|
||||
let dev = dev
|
||||
.with_interface(0x03, 0x00, 0x02, Some("mouse"), vec![ep(0x81, 8)],
|
||||
handler(IdleHidHandler { report_desc: RDESC_MOUSE.to_vec() }))
|
||||
.with_interface(0x03, 0x01, 0x01, Some("keyboard"), vec![ep(0x82, 8)],
|
||||
handler(IdleHidHandler { report_desc: RDESC_KBD.to_vec() }))
|
||||
.with_interface(0x03, 0x00, 0x00, Some("controller"), vec![ep(0x83, 64)],
|
||||
handler(ControllerHandler { report_desc: RDESC_CTRL.to_vec(), last_set: vec![], seq: 0, press_a }));
|
||||
|
||||
let server = Arc::new(UsbIpServer::new_simulated(vec![dev]));
|
||||
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 3240);
|
||||
println!("usbip Deck server on {addr} (press_a={press_a}); attach with: usbip attach -r 127.0.0.1 -b 0-0-0");
|
||||
usbip::server(addr, server).await;
|
||||
}
|
||||
Reference in New Issue
Block a user