Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b73361372 | |||
| d2b4e3d71c | |||
| 0bca67f73e | |||
| 9d67dc18aa | |||
| b349724fe9 | |||
| 32e5594a9a | |||
| f4f6c5556f | |||
| 5c7e0afa99 | |||
| 5a384fe788 | |||
| a2433d77cf | |||
| a87b279c2b | |||
| 9bf72cdfb5 | |||
| a1af916e38 | |||
| 46b7ffc001 | |||
| 9b7fc127ef | |||
| 1a559e8d5e | |||
| 160914c48b | |||
| ed0ce5dc6d | |||
| f2fa7828d6 | |||
| 85513d1697 | |||
| 0058f624a2 | |||
| a7a1e871e8 | |||
| 840e5d590e | |||
| d58524c899 | |||
| 6db91cbf40 | |||
| 60d4653083 | |||
| 927a571414 | |||
| f3b6ccaa7f | |||
| d8e8529cd7 | |||
| 4201851c7f | |||
| eb4bca11c5 | |||
| 69f30f30b6 | |||
| f7356d0820 | |||
| 51cdaea3f3 | |||
| ea2e3578e2 | |||
| 8d8168b0e0 | |||
| 61c752e91e | |||
| 8c854e0a19 | |||
| 70a74b0d7c | |||
| 41be73fbc6 | |||
| 1830e095f8 | |||
| 45bde370e2 | |||
| 57d89217fb | |||
| 650acda334 | |||
| 89aa52bc58 | |||
| 384fc30833 | |||
| 365d4bb8f1 | |||
| f1efd3091e | |||
| 446818eea6 | |||
| 4d6c2394dc | |||
| 2bea02b0ea | |||
| 528a51d75c | |||
| b597bb74bd |
@@ -0,0 +1,18 @@
|
||||
# Workspace-wide build flags.
|
||||
#
|
||||
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
|
||||
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
|
||||
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
|
||||
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
|
||||
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
|
||||
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
|
||||
# extensions falls back safely.
|
||||
#
|
||||
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
|
||||
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
|
||||
# `--cfg aes_armv8` themselves.
|
||||
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
|
||||
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
|
||||
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
|
||||
[target.'cfg(target_arch = "aarch64")']
|
||||
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
|
||||
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## Before you push
|
||||
|
||||
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
|
||||
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
|
||||
on formatting alone:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
Then the usual full pass:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2778,7 +2778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,7 +3039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3058,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3089,7 +3089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3161,7 +3161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3175,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
@@ -35,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<!-- Neutralize Play's IMPLIED hard requirements, which filtered real TVs as "not compatible"
|
||||
(reported on a Philips OLED707): RECORD_AUDIO implies android.hardware.microphone and the
|
||||
Wi-Fi state permissions imply android.hardware.wifi, both required=true unless declared
|
||||
otherwise. Some TVs declare no microphone (mic uplink is optional and runtime-gated) and
|
||||
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
|
||||
@@ -387,6 +387,8 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||
else -> "Automatic"
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
|
||||
val rows = buildSettingsRows(s, ::update)
|
||||
val context = LocalContext.current
|
||||
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update]. */
|
||||
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
|
||||
private fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
) { update(s.copy(rumbleOnPhone = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -153,7 +154,18 @@ class MainActivity : ComponentActivity() {
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
if (vk != 0) {
|
||||
// Soft-keyboard events (the IME's virtual device — the stream's
|
||||
// KeyCaptureView path) carry Shift only as META state, where a real
|
||||
// keyboard sends discrete Shift transitions — so mirror the meta bit as
|
||||
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
|
||||
// host. Never applied to hardware events: their Shift already went over
|
||||
// the wire, and a synthetic release here would un-hold a physical Shift
|
||||
// the user is still pressing.
|
||||
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
|
||||
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
|
||||
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, vk, down, 0)
|
||||
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
|
||||
return true // consumed — don't let the system also act on it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,14 @@ data class Settings(
|
||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||
*/
|
||||
val autoWakeEnabled: Boolean = true,
|
||||
/**
|
||||
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
|
||||
* the phone body is the only actuator in the player's hands. Off by default; read once per
|
||||
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -142,6 +150,7 @@ class SettingsStore(context: Context) {
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -162,6 +171,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -197,6 +207,7 @@ class SettingsStore(context: Context) {
|
||||
*/
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
|
||||
/**
|
||||
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||
@@ -414,6 +415,18 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
subtitle = "What the app detects, with a live input test",
|
||||
onClick = onOpenControllers,
|
||||
)
|
||||
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
|
||||
val context = LocalContext.current
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
if (hasBodyVibrator) {
|
||||
ToggleRow(
|
||||
title = "Rumble on this phone",
|
||||
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
|
||||
"motor — for clip-on pads without rumble motors",
|
||||
checked = s.rumbleOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,22 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -34,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -166,6 +174,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
val priorSoftInput = window?.attributes?.softInputMode
|
||||
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
|
||||
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
|
||||
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||
@@ -188,8 +202,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed.
|
||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
||||
val feedback = GamepadFeedback(
|
||||
handle,
|
||||
router,
|
||||
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
|
||||
).also { it.start() }
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
@@ -201,6 +220,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@@ -221,6 +242,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -271,8 +295,16 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||
AndroidView(
|
||||
modifier = Modifier.size(1.dp),
|
||||
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||
)
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
@@ -281,9 +313,45 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||
* `MainActivity.dispatchKeyEvent`).
|
||||
*/
|
||||
private class KeyCaptureView(context: Context) : View(context) {
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
return BaseInputConnection(this, false)
|
||||
}
|
||||
|
||||
fun setImeVisible(show: Boolean) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
?: return
|
||||
if (show) {
|
||||
requestFocus()
|
||||
imm.showSoftInput(this, 0)
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ private const val TAP_SLOP = 12f
|
||||
private const val TAP_DRAG_MS = 250L
|
||||
private const val SCROLL_DIV = 4f
|
||||
|
||||
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
|
||||
// summon (up) / dismiss (down) the local soft keyboard.
|
||||
private const val KB_SWIPE_FRACTION = 0.10f
|
||||
|
||||
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
|
||||
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
|
||||
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||
@@ -40,7 +44,9 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
|
||||
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
|
||||
* typing on the host).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -94,6 +100,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -128,6 +135,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
var maxFingers = 1
|
||||
var scrolling = false
|
||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
|
||||
// scroll anchor) and a once-per-gesture latch.
|
||||
var kbCount = 0
|
||||
var kbAnchorX = 0f
|
||||
var kbAnchorY = 0f
|
||||
var kbFired = false
|
||||
var prevCx = startX
|
||||
var prevCy = startY
|
||||
var upTime = down.uptimeMillis
|
||||
@@ -148,9 +161,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
break
|
||||
}
|
||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
|
||||
// bounce re-anchors instead of reading the count change as swipe travel.
|
||||
if (pressed.size < 3) kbCount = 0
|
||||
|
||||
if (pressed.size >= 2) {
|
||||
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||
if (pressed.size == 2) {
|
||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||
@@ -177,6 +193,36 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
} else if (pressed.size >= 3) {
|
||||
// Three+ fingers → the keyboard swipe, never scroll (the documented
|
||||
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
|
||||
// accident of its old `>= 2` bound). Anchor the centroid per finger count
|
||||
// (same reasoning as the scroll anchor above) and fire once per gesture when
|
||||
// the vertical travel crosses the threshold: up = show, down = hide.
|
||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
|
||||
if (pressed.size != kbCount) {
|
||||
kbCount = pressed.size
|
||||
kbAnchorX = cx
|
||||
kbAnchorY = cy
|
||||
} else {
|
||||
val dy = cy - kbAnchorY
|
||||
// Real centroid travel disqualifies the tap classification below (else a
|
||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
|
||||
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
|
||||
kbFired = true
|
||||
onKeyboard(dy < 0) // finger up → show, finger down → hide
|
||||
}
|
||||
}
|
||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
|
||||
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
|
||||
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
|
||||
// down, so dropping straight back to one finger must re-anchor (zero delta),
|
||||
// not replay the whole 3-finger phase as one cursor jump.
|
||||
scrolling = false
|
||||
scrollCount = 0
|
||||
trackId = PointerId(Long.MIN_VALUE)
|
||||
} else if (!scrolling) {
|
||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||
// back to one finger doesn't jerk the cursor).
|
||||
|
||||
@@ -52,6 +52,8 @@ object Gamepad {
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
const val PREF_STEAMCONTROLLER = 5
|
||||
const val PREF_STEAMDECK = 6
|
||||
const val PREF_DUALSENSEEDGE = 7
|
||||
const val PREF_SWITCHPRO = 8
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
@@ -59,10 +61,19 @@ object Gamepad {
|
||||
private const val VID_VALVE = 0x28DE
|
||||
private const val VID_NINTENDO = 0x057E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||
// host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
|
||||
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
|
||||
private val PID_DUALSENSE = setOf(0x0CE6)
|
||||
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
|
||||
// glyphs + positional layout). The Switch 2 Pro Controller (0x2069) and a Joy-Con 2 pair
|
||||
// (0x2068) are the same full pad surface and ride the same virtual pad (SDL folds them to
|
||||
// its NINTENDO_SWITCH_PRO type too).
|
||||
private val PID_SWITCHPRO = setOf(0x2009, 0x2069, 0x2068)
|
||||
|
||||
// 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
|
||||
@@ -91,10 +102,12 @@ object Gamepad {
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_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
|
||||
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
@@ -33,8 +34,18 @@ import java.nio.ByteBuffer
|
||||
*
|
||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
*
|
||||
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
|
||||
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
|
||||
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
|
||||
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
|
||||
* setting is on (see [deviceBodyVibrator]).
|
||||
*/
|
||||
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||
class GamepadFeedback(
|
||||
private val handle: Long,
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
@@ -127,7 +138,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
||||
// lights session.
|
||||
runCatching { deviceVibrator?.cancel() }
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
@@ -203,6 +216,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
*/
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||
// already decided the bind, and the user opted in.
|
||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
@@ -246,6 +264,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
|
||||
* one physical actuator, so both wire motors blend into one effect (the same blend as the
|
||||
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
|
||||
* TTL, cancel on (0,0).
|
||||
*/
|
||||
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
|
||||
val v = deviceVibrator ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
runCatching { v.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
v.vibrate(
|
||||
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
|
||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
@@ -349,3 +390,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
||||
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
||||
* [GamepadFeedback.deviceVibrator] when it's on.
|
||||
*/
|
||||
fun deviceBodyVibrator(context: Context): Vibrator? {
|
||||
val v = if (Build.VERSION.SDK_INT >= 31) {
|
||||
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
return v?.takeIf { it.hasVibrator() }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<string>MicroGamepad</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
|
||||
@@ -15,6 +15,9 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
|
||||
#if os(iOS)
|
||||
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
return [
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
// practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||
label: "Rumble on this iPhone",
|
||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||
+ "for clip-on pads without rumble motors.",
|
||||
value: $rumbleOnDevice),
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||
// composed by the per-platform bodies in SettingsView.swift.
|
||||
|
||||
#if os(iOS)
|
||||
import CoreHaptics
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@@ -471,6 +474,12 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
#endif
|
||||
@@ -487,6 +496,11 @@ extension SettingsView {
|
||||
// for its own footer and has no such toggle to describe.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(Self.controllersFooter)
|
||||
#if os(iOS)
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
Text(Self.deviceRumbleFooter)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Text(Self.gamepadUIFooter)
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,13 @@ extension SettingsView {
|
||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||
+ "Applies from the next session."
|
||||
|
||||
#if os(iOS)
|
||||
static let deviceRumbleFooter =
|
||||
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
|
||||
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
|
||||
+ "from the next session."
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
static let gamepadUIFooter =
|
||||
"When a controller connects, the host list and library switch to a controller-"
|
||||
|
||||
@@ -55,6 +55,7 @@ struct SettingsView: View {
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||||
// 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).
|
||||
|
||||
@@ -188,6 +188,14 @@ public final class PunktfunkConnection {
|
||||
// exist so the resolved type round-trips and name parsing matches the host.
|
||||
case steamController = 5
|
||||
case steamDeck = 6
|
||||
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
|
||||
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
|
||||
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
|
||||
/// rich planes match the physical pad.
|
||||
case dualSenseEdge = 7
|
||||
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
|
||||
/// glyphs + positional layout on the host side.
|
||||
case switchPro = 8
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -200,6 +208,9 @@ public final class PunktfunkConnection {
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
|
||||
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||
self = .switchPro
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
|
||||
import Combine
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
@@ -50,9 +51,26 @@ public final class GamepadFeedback {
|
||||
private let routingLock = NSLock()
|
||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||
|
||||
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
|
||||
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
|
||||
/// Engine — for phone-clip pads that ship without rumble motors, where the phone body is the
|
||||
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
|
||||
/// when off or where the device has no haptic actuator.
|
||||
private let deviceRumble: RumbleRenderer?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
#if os(iOS)
|
||||
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
|
||||
} else {
|
||||
deviceRumble = nil
|
||||
}
|
||||
#else
|
||||
deviceRumble = nil
|
||||
#endif
|
||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -189,6 +207,7 @@ public final class GamepadFeedback {
|
||||
return r
|
||||
}
|
||||
for r in renderers { r.stop() }
|
||||
deviceRumble?.stop()
|
||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||
// teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
@@ -203,6 +222,10 @@ public final class GamepadFeedback {
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
||||
}
|
||||
|
||||
private func withRouting<R>(_ body: () -> R) -> R {
|
||||
|
||||
@@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject {
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||
/// in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates
|
||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||
public var hasTouchpadAndMotion: Bool {
|
||||
kind == .dualSense || kind == .dualShock4
|
||||
kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
|
||||
}
|
||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||
public let batteryLevel: Float?
|
||||
@@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let kind = padKind(extended)
|
||||
let kind = padKind(extended, productCategory: c.productCategory)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
@@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject {
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||
// Edge included); the DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||
/// `GCDualSenseGamepad` too — its distinct product category splits it out), then
|
||||
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
|
||||
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
|
||||
/// (it's never forwarded anyway).
|
||||
private static func padKind(
|
||||
_ extended: GCExtendedGamepad?
|
||||
_ extended: GCExtendedGamepad?,
|
||||
productCategory: String
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
let category = productCategory.lowercased()
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||
if extended is GCDualSenseGamepad { return .dualSense }
|
||||
if extended is GCDualSenseGamepad {
|
||||
return category.contains("edge") ? .dualSenseEdge : .dualSense
|
||||
}
|
||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||
if extended is GCXboxGamepad { return .xboxOne }
|
||||
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
|
||||
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad.
|
||||
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
|
||||
return .switchPro
|
||||
}
|
||||
return .xbox360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
static let manual = Policy(staleAfter: nil)
|
||||
}
|
||||
|
||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in
|
||||
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
|
||||
/// Device mode ignores `retarget`'s controller and always renders one combined motor
|
||||
/// (a phone body has a single actuator).
|
||||
enum Actuator {
|
||||
case controller
|
||||
case device
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
private let policy: Policy
|
||||
private let actuator: Actuator
|
||||
|
||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
||||
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||
#endif
|
||||
|
||||
init(policy: Policy = .session) {
|
||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
||||
self.policy = policy
|
||||
self.actuator = actuator
|
||||
}
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||
private func setup() {
|
||||
if actuator == .device {
|
||||
setupDevice()
|
||||
return
|
||||
}
|
||||
guard let haptics = controller?.haptics else {
|
||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
||||
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
|
||||
/// iPhone has one — everything else (iPad, Mac, TV) reports no haptic hardware and latches
|
||||
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
|
||||
private func setupDevice() {
|
||||
#if os(iOS)
|
||||
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
|
||||
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
|
||||
broken = true
|
||||
reportHealth("This device has no haptic actuator.")
|
||||
return
|
||||
}
|
||||
do {
|
||||
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
|
||||
} catch {
|
||||
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
|
||||
}
|
||||
if low == nil {
|
||||
// Same shape as the controller path: haptics exist but the engine couldn't be built
|
||||
// right now — back off and retry, don't latch off.
|
||||
scheduleRetryBackoff()
|
||||
}
|
||||
#else
|
||||
broken = true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func makeMotor(
|
||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||
) -> Motor? {
|
||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
||||
return startMotor(engine, sharpness: sharpness)
|
||||
}
|
||||
|
||||
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
|
||||
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
|
||||
// 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
|
||||
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
try engine.start()
|
||||
return Motor(engine: engine, sharpness: sharpness)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
log.warning("haptic engine setup failed: \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,44 @@ extension InputCapture {
|
||||
]
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// US-layout character → Windows VK for the on-screen keyboard (`StreamLayerUIView`'s
|
||||
/// UIKeyInput). Unlike every other key source, `insertText` delivers CHARACTERS, not key
|
||||
/// positions, so this is the inverse of a US layout: `shift` means "wrap in VK_LSHIFT so the
|
||||
/// host types the shifted symbol". Same contract as `hidToVK`: emit only VKs the host's
|
||||
/// vk_to_evdev knows; anything unmapped is dropped by the caller.
|
||||
enum SoftKeyMap {
|
||||
static func vk(for ch: Character) -> (vk: UInt32, shift: Bool)? {
|
||||
guard let ascii = ch.asciiValue else { return nil }
|
||||
switch ascii {
|
||||
case UInt8(ascii: "a")...UInt8(ascii: "z"): return (UInt32(ascii) - 0x20, false)
|
||||
case UInt8(ascii: "A")...UInt8(ascii: "Z"): return (UInt32(ascii), true)
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"): return (UInt32(ascii), false)
|
||||
case 0x0A, 0x0D: return (0x0D, false) // return
|
||||
case 0x09: return (0x09, false) // tab
|
||||
case 0x20: return (0x20, false) // space
|
||||
default: return symbols[ch]
|
||||
}
|
||||
}
|
||||
|
||||
/// US punctuation, plain and shifted, on the OEM VKs (mirrors `hidToVK`'s OEM block) plus
|
||||
/// the shifted digit row.
|
||||
private static let symbols: [Character: (vk: UInt32, shift: Bool)] = [
|
||||
"-": (0xBD, false), "_": (0xBD, true),
|
||||
"=": (0xBB, false), "+": (0xBB, true),
|
||||
"[": (0xDB, false), "{": (0xDB, true),
|
||||
"]": (0xDD, false), "}": (0xDD, true),
|
||||
"\\": (0xDC, false), "|": (0xDC, true),
|
||||
";": (0xBA, false), ":": (0xBA, true),
|
||||
"'": (0xDE, false), "\"": (0xDE, true),
|
||||
"`": (0xC0, false), "~": (0xC0, true),
|
||||
",": (0xBC, false), "<": (0xBC, true),
|
||||
".": (0xBE, false), ">": (0xBE, true),
|
||||
"/": (0xBF, false), "?": (0xBF, true),
|
||||
"!": (0x31, true), "@": (0x32, true), "#": (0x33, true), "$": (0x34, true),
|
||||
"%": (0x35, true), "^": (0x36, true), "&": (0x37, true), "*": (0x38, true),
|
||||
"(": (0x39, true), ")": (0x30, true),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||
// (off → compact → normal → detailed, matching Android):
|
||||
// (off → compact → normal → detailed, matching Android) · three-finger swipe up/down =
|
||||
// summon/dismiss the local soft keyboard for typing on the host (`onKeyboardGesture`):
|
||||
//
|
||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
@@ -61,6 +62,9 @@ final class TouchMouse {
|
||||
static let accelGain: CGFloat = 0.6
|
||||
static let accelSpeedFloor: CGFloat = 0.3
|
||||
static let accelMax: CGFloat = 3.0
|
||||
/// Three-finger vertical swipe: the fraction of the view height the centroid must
|
||||
/// travel to summon (up) / dismiss (down) the local soft keyboard.
|
||||
static let keyboardSwipeFraction: CGFloat = 0.10
|
||||
|
||||
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||
@@ -72,6 +76,9 @@ final class TouchMouse {
|
||||
var send: ((PunktfunkInputEvent) -> Void)?
|
||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
||||
/// Three-finger vertical swipe crossed the threshold: `true` = show the local soft
|
||||
/// keyboard (swipe up), `false` = dismiss it (swipe down). Fires at most once per gesture.
|
||||
var onKeyboardGesture: ((Bool) -> Void)?
|
||||
|
||||
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||
@@ -95,6 +102,11 @@ final class TouchMouse {
|
||||
private var carryY: CGFloat = 0
|
||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||
private var scrollAnchor = CGPoint.zero
|
||||
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the scroll
|
||||
// anchor) and a once-per-gesture latch.
|
||||
private var kbCount = 0
|
||||
private var kbAnchor = CGPoint.zero
|
||||
private var kbFired = false
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
private var lastTapUp: TimeInterval = 0
|
||||
private var lastTapPoint = CGPoint.zero
|
||||
@@ -114,6 +126,8 @@ final class TouchMouse {
|
||||
maxFingers = 0
|
||||
moved = false
|
||||
scrolling = false
|
||||
kbCount = 0
|
||||
kbFired = false
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
// button for this whole gesture (laptop-trackpad convention).
|
||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||
@@ -140,8 +154,13 @@ final class TouchMouse {
|
||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||
}
|
||||
if lastPos.count >= 2 {
|
||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3 bounce
|
||||
// re-anchors instead of reading the count change as swipe travel.
|
||||
if lastPos.count < 3 { kbCount = 0 }
|
||||
if lastPos.count == 2 {
|
||||
scrollByCentroid()
|
||||
} else if lastPos.count >= 3 {
|
||||
keyboardSwipe(in: view)
|
||||
} else if !scrolling, let touch = touches.first(where: {
|
||||
lastPos[ObjectIdentifier($0)] != nil
|
||||
}) {
|
||||
@@ -208,9 +227,9 @@ final class TouchMouse {
|
||||
|
||||
// MARK: - Per-event work
|
||||
|
||||
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
||||
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
||||
/// scrolls right (the host WHEEL(120) convention).
|
||||
private func scrollByCentroid() {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
@@ -233,6 +252,38 @@ final class TouchMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three+ fingers → the keyboard swipe, never scroll (the documented vocabulary is
|
||||
/// TWO-finger scroll; 3+ only fell into the scroll path as an accident of its old `>= 2`
|
||||
/// bound). The centroid is anchored per finger count — real fingers never land or lift in
|
||||
/// the same event, so a count change must re-anchor rather than read as travel — and the
|
||||
/// gesture fires at most once, when the vertical travel crosses the threshold: up = show
|
||||
/// the local soft keyboard, down = dismiss it.
|
||||
private func keyboardSwipe(in view: UIView) {
|
||||
let n = CGFloat(lastPos.count)
|
||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
|
||||
if lastPos.count != kbCount {
|
||||
kbCount = lastPos.count
|
||||
kbAnchor = CGPoint(x: cx, y: cy)
|
||||
} else {
|
||||
let dy = cy - kbAnchor.y
|
||||
// Real centroid travel disqualifies the tap classification in `ended` (else a
|
||||
// sub-threshold swipe would still fire the three-finger stats tap).
|
||||
if abs(dy) > Tuning.tapSlop || abs(cx - kbAnchor.x) > Tuning.tapSlop { moved = true }
|
||||
if !kbFired, abs(dy) >= view.bounds.height * Tuning.keyboardSwipeFraction {
|
||||
kbFired = true
|
||||
onKeyboardGesture?(dy < 0) // finger up → show, finger down → dismiss
|
||||
}
|
||||
}
|
||||
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel notch;
|
||||
// clearing it makes a return to two fingers re-anchor fresh. Same for the trackpad's
|
||||
// tracked finger: its prev position froze while 3+ fingers were down, so dropping
|
||||
// straight back to one finger must re-anchor (zero delta), not replay the whole
|
||||
// 3-finger phase as one cursor jump.
|
||||
scrolling = false
|
||||
trackKey = nil
|
||||
}
|
||||
|
||||
/// One finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||
/// one must not jerk the cursor).
|
||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||
|
||||
@@ -97,6 +97,12 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
|
||||
/// has a haptic actuator (no iPad/Mac/TV).
|
||||
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
|
||||
@@ -698,6 +698,7 @@ final class StreamLayerUIView: UIView {
|
||||
let mouse = TouchMouse()
|
||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
||||
return mouse
|
||||
}()
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
@@ -708,6 +709,22 @@ final class StreamLayerUIView: UIView {
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
fingerRoute = nil
|
||||
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||
}
|
||||
|
||||
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
|
||||
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
|
||||
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
|
||||
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead — see
|
||||
/// StreamViewController.viewDidAppear).
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
func setSoftKeyboardVisible(_ visible: Bool) {
|
||||
if visible {
|
||||
becomeFirstResponder()
|
||||
} else if isFirstResponder {
|
||||
resignFirstResponder()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -879,4 +896,46 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// The soft keyboard's output → wire key events. UIKeyInput is deliberately minimal (no
|
||||
// UITextInput): the stream needs keystrokes, not an editing buffer — insertions map through
|
||||
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
|
||||
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
|
||||
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
|
||||
// gated on captureEnabled with everything else and can't leak past a trust prompt.
|
||||
extension StreamLayerUIView: UIKeyInput {
|
||||
// Keep the IME literal — no autocorrect/smart substitutions; a remote desktop is not prose,
|
||||
// and the host does its own text handling.
|
||||
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
|
||||
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
|
||||
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
|
||||
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
|
||||
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
|
||||
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
|
||||
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
|
||||
|
||||
var hasText: Bool { false }
|
||||
|
||||
func insertText(_ text: String) {
|
||||
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
|
||||
// UIKeyInput insertions while we're first responder — forwarding both would double
|
||||
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
|
||||
guard GCKeyboard.coalesced == nil else { return }
|
||||
for ch in text {
|
||||
guard let key = SoftKeyMap.vk(for: ch) else { continue }
|
||||
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
|
||||
onTouchEvent?(.key(key.vk, down: true))
|
||||
onTouchEvent?(.key(key.vk, down: false))
|
||||
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBackward() {
|
||||
guard GCKeyboard.coalesced == nil else { return } // see insertText
|
||||
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
|
||||
onTouchEvent?(.key(0x08, down: false))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
@@ -12,7 +12,7 @@
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required)
|
||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
@@ -36,24 +36,31 @@ set -u
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
||||
# session end returns to the launcher, B quits back to Gaming Mode.
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"controller_mappings"
|
||||
{
|
||||
"version" "3"
|
||||
"revision" "2"
|
||||
"title" "Punktfunk"
|
||||
"description" "Native touchscreen + full gamepad passthrough for the Punktfunk streaming client."
|
||||
"creator" "0"
|
||||
"progenitor" "template://controller_neptune_gamepad_fps.vdf"
|
||||
"url" "template://controller_neptune_gamepad_fps.vdf"
|
||||
"export_type" "unknown"
|
||||
"controller_type" "controller_neptune"
|
||||
"controller_caps" "23117823"
|
||||
"major_revision" "0"
|
||||
"minor_revision" "0"
|
||||
"Timestamp" "0"
|
||||
"localization"
|
||||
{
|
||||
"english"
|
||||
{
|
||||
"title" "Punktfunk"
|
||||
"description" "Native touchscreen + full gamepad for Punktfunk streaming."
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "0"
|
||||
"mode" "four_buttons"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_a"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button A, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_b"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button B, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_x"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button X, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_y"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button Y, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "1"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_up, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_down, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_right, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button dpad_left, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "2"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "3"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"deadzone_inner_radius" "7199"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "4"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "1"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "5"
|
||||
"mode" "trigger"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"output_trigger" "2"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "6"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "8"
|
||||
"mode" "joystick_move"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "9"
|
||||
"mode" "dpad"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_UP, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_south"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_DOWN, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_east"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_RIGHT, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"dpad_west"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_LEFT, , "
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"haptic_intensity" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"requires_click" "0"
|
||||
"haptic_intensity_override" "0"
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "10"
|
||||
"mode" "single_button"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button START, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "11"
|
||||
"mode" "single_button"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button SELECT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "12"
|
||||
"mode" "mouse_joystick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Soft_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "13"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "14"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "15"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_RIGHT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "16"
|
||||
"mode" "flickstick"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"click"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button JOYSTICK_LEFT, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "7"
|
||||
"mode" "switches"
|
||||
"name" ""
|
||||
"description" ""
|
||||
"inputs"
|
||||
{
|
||||
"button_escape"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button start, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_menu"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button select, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"left_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_left, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"right_bumper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button shoulder_right, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_left"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_right"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_left_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"button_back_right_upper"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
"always_on_action"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "controller_action ts_n, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
"disabled_activators"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"preset"
|
||||
{
|
||||
"id" "0"
|
||||
"name" "Default"
|
||||
"group_source_bindings"
|
||||
{
|
||||
"7" "switch active"
|
||||
"0" "button_diamond active"
|
||||
"1" "left_trackpad active"
|
||||
"11" "left_trackpad inactive"
|
||||
"16" "left_trackpad inactive"
|
||||
"2" "right_trackpad inactive"
|
||||
"6" "right_trackpad inactive"
|
||||
"10" "right_trackpad inactive"
|
||||
"12" "right_trackpad active"
|
||||
"15" "right_trackpad inactive"
|
||||
"3" "joystick active"
|
||||
"14" "joystick inactive"
|
||||
"4" "left_trigger active"
|
||||
"5" "right_trigger active"
|
||||
"8" "right_joystick active"
|
||||
"13" "right_joystick inactive"
|
||||
"9" "dpad active"
|
||||
}
|
||||
}
|
||||
"settings"
|
||||
{
|
||||
"left_trackpad_mode" "0"
|
||||
"right_trackpad_mode" "0"
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,93 @@ def _pins_path() -> Path:
|
||||
return _client_config_dir() / "decky-pinned.json"
|
||||
|
||||
|
||||
# --- Steam Input controller config injection (native touchscreen via the ts_n command) --------
|
||||
# The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with
|
||||
# the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We
|
||||
# ship that layout (controller_config/punktfunk.vdf, built on Steam's gamepad-fps template) and
|
||||
# point our shortcuts at it, EmuDeck-style: drop it in controller_base/templates/ (so it is also
|
||||
# a selectable "Punktfunk" template) AND set each account's configset entry for our shortcut's
|
||||
# game key to that template. Steam keys non-Steam games by their LOWERCASE NAME (verified on the
|
||||
# Deck: our "Punktfunk" shortcut → the "punktfunk" configset key), so both our shortcuts (same
|
||||
# name) share one entry. controller_neptune = the Deck's built-in controller type.
|
||||
CONTROLLER_TEMPLATE = "punktfunk.vdf"
|
||||
|
||||
|
||||
def _steam_root() -> Path:
|
||||
"""Steam's base dir on SteamOS (~/.steam/steam symlinks here)."""
|
||||
return Path(decky.DECKY_USER_HOME) / ".local" / "share" / "Steam"
|
||||
|
||||
|
||||
def _controller_template_src() -> Path:
|
||||
return Path(decky.DECKY_PLUGIN_DIR) / "controller_config" / CONTROLLER_TEMPLATE
|
||||
|
||||
|
||||
def _chown_like_parent(path: Path) -> None:
|
||||
"""The Decky backend runs as root, so files it CREATES in the deck-owned Steam tree land
|
||||
root-owned — which would stop Steam (running as the user) from rewriting them. Match the
|
||||
parent dir's owner so Steam retains write access. Best-effort."""
|
||||
try:
|
||||
st = path.parent.stat()
|
||||
os.chown(path, st.st_uid, st.st_gid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _configset_dirs() -> list[Path]:
|
||||
"""Every Steam account's controller-config dir holding configset_controller_neptune.vdf."""
|
||||
base = _steam_root() / "steamapps" / "common" / "Steam Controller Configs"
|
||||
return [p / "config" for p in sorted(base.glob("*")) if (p / "config").is_dir()]
|
||||
|
||||
|
||||
def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: str) -> str:
|
||||
"""Set the top-level ``"<key>" { "<source_type>" "<source_val>" }`` block in a
|
||||
configset_controller_neptune.vdf, replacing any existing block for that key (case-insensitive)
|
||||
or inserting one before the file's final closing brace. Targeted (only our key is touched) so
|
||||
the hundreds of other game entries stay byte-for-byte intact. Creates the wrapping
|
||||
``"controller_config" { }`` skeleton when the file is empty/new."""
|
||||
block = f'\t"{key}"\n\t{{\n\t\t"{source_type}"\t\t"{source_val}"\n\t}}\n'
|
||||
if '"controller_config"' not in text:
|
||||
return '"controller_config"\n{\n' + block + "}\n"
|
||||
|
||||
lower = text.lower()
|
||||
needle = f'"{key.lower()}"'
|
||||
# Find the key token that begins a top-level entry (its own line), then its "{ … }" block.
|
||||
search_from = 0
|
||||
while True:
|
||||
idx = lower.find(needle, search_from)
|
||||
if idx == -1:
|
||||
break
|
||||
# Must be a standalone key line (preceded only by whitespace back to a newline).
|
||||
line_start = text.rfind("\n", 0, idx) + 1
|
||||
if text[line_start:idx].strip() != "":
|
||||
search_from = idx + len(needle)
|
||||
continue
|
||||
brace = text.find("{", idx)
|
||||
if brace == -1:
|
||||
break
|
||||
depth = 0
|
||||
i = brace
|
||||
while i < len(text):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
end = i + 1
|
||||
# Consume the trailing newline after the block so we don't accumulate blank lines.
|
||||
if end < len(text) and text[end] == "\n":
|
||||
end += 1
|
||||
return text[:line_start] + block + text[end:]
|
||||
|
||||
# Not present — insert before the last closing brace (the controller_config block's end).
|
||||
last_close = text.rstrip().rfind("}")
|
||||
if last_close == -1:
|
||||
return text.rstrip() + "\n" + block
|
||||
return text[:last_close] + block + text[last_close:]
|
||||
|
||||
|
||||
def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
||||
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
||||
@@ -726,10 +813,10 @@ class Plugin:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def shortcut_art(self) -> dict:
|
||||
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
|
||||
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
|
||||
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
|
||||
files are simply omitted — artwork is cosmetic and must never block a launch."""
|
||||
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
||||
base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
|
||||
absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
|
||||
simply omitted — artwork is cosmetic and must never block a launch."""
|
||||
art: dict = {}
|
||||
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
||||
for key, fname in (
|
||||
@@ -746,6 +833,54 @@ class Plugin:
|
||||
art["icon_path"] = str(icon) if icon.exists() else ""
|
||||
return art
|
||||
|
||||
async def apply_controller_config(self, name: str = "Punktfunk") -> dict:
|
||||
"""Install our Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and
|
||||
point the shortcut(s) at it, so the Deck touchscreen reaches the client as native touch
|
||||
with zero manual controller setup. Best-effort + idempotent — a controller tweak must
|
||||
never block a launch, so failures are reported, not raised. Both shortcuts share the same
|
||||
name → the same lowercase configset key, so one entry per account covers both."""
|
||||
src = _controller_template_src()
|
||||
if not src.exists():
|
||||
return {"ok": False, "error": "template-missing", "detail": str(src)}
|
||||
key = name.strip().lower()
|
||||
applied: list[str] = []
|
||||
errors: list[str] = []
|
||||
# 1) Ship it as a selectable template (also the safe fallback if Steam clobbers the
|
||||
# configset write on exit): controller_base/templates/punktfunk.vdf.
|
||||
try:
|
||||
tdir = _steam_root() / "controller_base" / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
dst = tdir / CONTROLLER_TEMPLATE
|
||||
shutil.copyfile(src, dst)
|
||||
_chown_like_parent(dst)
|
||||
applied.append("template")
|
||||
except OSError as e:
|
||||
errors.append(f"template: {e}")
|
||||
# 2) Point each Steam account's configset at that template for our game key.
|
||||
dirs = _configset_dirs()
|
||||
for d in dirs:
|
||||
f = d / "configset_controller_neptune.vdf"
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8") if f.exists() else ""
|
||||
new = _upsert_configset_entry(text, key, "template", CONTROLLER_TEMPLATE)
|
||||
if new != text:
|
||||
if f.exists(): # keep one recoverable backup before our first edit
|
||||
bak = f.with_name(f.name + ".pf-bak")
|
||||
if not bak.exists():
|
||||
shutil.copyfile(f, bak)
|
||||
_chown_like_parent(bak)
|
||||
existed = f.exists()
|
||||
f.write_text(new, encoding="utf-8")
|
||||
if not existed: # a freshly-created file is root-owned — hand it to the user
|
||||
_chown_like_parent(f)
|
||||
applied.append(f"configset:{d.parent.name}")
|
||||
except OSError as e:
|
||||
errors.append(f"{d.parent.name}: {e}")
|
||||
decky.logger.info(
|
||||
"apply_controller_config key=%s applied=%s errors=%s", key, applied, errors
|
||||
)
|
||||
return {"ok": not errors, "applied": applied, "errors": errors, "accounts": len(dirs)}
|
||||
|
||||
async def runner_info(self) -> dict:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
|
||||
|
||||
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
|
||||
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
|
||||
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
|
||||
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
|
||||
needs only p·u·n·k·t·f). The frontend applies them via
|
||||
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
|
||||
|
||||
Outputs (checked in; re-run only when the brand changes):
|
||||
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
|
||||
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
|
||||
clients/decky/assets/hero.png 1920 x 620 game-page banner
|
||||
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
|
||||
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
|
||||
|
||||
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
|
||||
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
|
||||
supersampling.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # clients/decky
|
||||
OUT = HERE / "assets"
|
||||
|
||||
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
|
||||
R = 194.41
|
||||
C1 = (403.037, 597.262) # light circle, behind
|
||||
C2 = (597.8075, 402.8525) # deep circle, in front
|
||||
BB_MIN = (C1[0] - R, C2[1] - R)
|
||||
BB_MAX = (C2[0] + R, C1[1] + R)
|
||||
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
||||
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
|
||||
|
||||
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
||||
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
||||
COL_HI = (0xD2, 0xC9, 0xFB)
|
||||
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
|
||||
BG_TOP = (0x28, 0x1E, 0x46)
|
||||
BG_BOT = (0x12, 0x0D, 0x22)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
|
||||
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
|
||||
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
def _arc(cx, cy, r, a0, a1, n=24):
|
||||
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
a = math.radians(a0 + (a1 - a0) * i / n)
|
||||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||||
return pts
|
||||
|
||||
|
||||
GLYPHS = {
|
||||
# letter: (advance, [polyline, ...])
|
||||
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
|
||||
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
|
||||
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
|
||||
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
|
||||
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
|
||||
"f": (
|
||||
0.85,
|
||||
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
|
||||
),
|
||||
}
|
||||
GAP = 0.34 # inter-letter gap, in glyph units
|
||||
STROKE = 0.26 # stroke thickness, in glyph units
|
||||
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
|
||||
|
||||
|
||||
def word_segments(text):
|
||||
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
|
||||
segs = []
|
||||
x = 0.0
|
||||
for ch in text:
|
||||
adv, lines = GLYPHS[ch]
|
||||
for line in lines:
|
||||
for (x1, y1), (x2, y2) in zip(line, line[1:]):
|
||||
segs.append((x + x1, y1, x + x2, y2))
|
||||
x += adv + GAP
|
||||
return segs, x - GAP
|
||||
|
||||
|
||||
def render_word_alpha(text, unit_px):
|
||||
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
|
||||
segs, width_u = word_segments(text)
|
||||
half = STROKE / 2 * unit_px
|
||||
pad = half + 1.5
|
||||
w = math.ceil(width_u * unit_px + 2 * pad)
|
||||
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
|
||||
ox, oy = pad, pad - ASCENT * unit_px
|
||||
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
|
||||
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
|
||||
buf = bytearray(w * h)
|
||||
for x1, y1, x2, y2 in px_segs:
|
||||
lo_x = max(0, math.floor(min(x1, x2) - pad))
|
||||
hi_x = min(w, math.ceil(max(x1, x2) + pad))
|
||||
lo_y = max(0, math.floor(min(y1, y2) - pad))
|
||||
hi_y = min(h, math.ceil(max(y1, y2) + pad))
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
len2 = dx * dx + dy * dy
|
||||
for py in range(lo_y, hi_y):
|
||||
row = py * w
|
||||
fy = py + 0.5
|
||||
for px in range(lo_x, hi_x):
|
||||
fx = px + 0.5
|
||||
if len2 > 0:
|
||||
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
|
||||
else:
|
||||
t = 0.0
|
||||
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
|
||||
cov = 0.5 + (half - d)
|
||||
if cov > 0:
|
||||
v = min(255, round(min(1.0, cov) * 255))
|
||||
if v > buf[row + px]:
|
||||
buf[row + px] = v
|
||||
return buf, w, h
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Canvas: RGBA bytearray, straight alpha, painted back to front.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
class Canvas:
|
||||
def __init__(self, w, h):
|
||||
self.w, self.h = w, h
|
||||
self.buf = bytearray(w * h * 4)
|
||||
|
||||
def fill_gradient(self, top, bottom):
|
||||
for y in range(self.h):
|
||||
t = y / max(1, self.h - 1)
|
||||
c = bytes(
|
||||
(
|
||||
round(top[0] + (bottom[0] - top[0]) * t),
|
||||
round(top[1] + (bottom[1] - top[1]) * t),
|
||||
round(top[2] + (bottom[2] - top[2]) * t),
|
||||
255,
|
||||
)
|
||||
)
|
||||
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
|
||||
|
||||
def _blend(self, i, rgb, a):
|
||||
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
|
||||
if a <= 0:
|
||||
return
|
||||
b = self.buf
|
||||
ia = 1.0 - a
|
||||
da = b[i + 3] / 255.0
|
||||
oa = a + da * ia
|
||||
if oa <= 0:
|
||||
return
|
||||
for k in range(3):
|
||||
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
|
||||
b[i + 3] = round(oa * 255)
|
||||
|
||||
def glow(self, cx, cy, radius, rgb, strength):
|
||||
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
|
||||
lo_x = max(0, math.floor(cx - 2.2 * radius))
|
||||
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
|
||||
lo_y = max(0, math.floor(cy - 2.2 * radius))
|
||||
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
|
||||
a = strength * math.exp(-2.5 * d2)
|
||||
if a > 1 / 255:
|
||||
self._blend((y * self.w + x) * 4, rgb, a)
|
||||
|
||||
def mark(self, cx, cy, span):
|
||||
"""The lens mark centered at (cx, cy) with the given pixel span."""
|
||||
scale = span / MARK_SPAN
|
||||
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
|
||||
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
|
||||
r = R * scale
|
||||
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
|
||||
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
|
||||
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
|
||||
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
|
||||
for y in range(lo_y, hi_y):
|
||||
for x in range(lo_x, hi_x):
|
||||
fx, fy = x + 0.5, y + 0.5
|
||||
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
|
||||
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
|
||||
if cov1 <= 0 and cov2 <= 0:
|
||||
continue
|
||||
i = (y * self.w + x) * 4
|
||||
self._blend(i, COL_LIGHT, cov1)
|
||||
self._blend(i, COL_DEEP, cov2)
|
||||
self._blend(i, COL_HI, min(cov1, cov2))
|
||||
|
||||
def word(self, text, unit_px, cx, cy):
|
||||
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
|
||||
alpha, w, h = render_word_alpha(text, unit_px)
|
||||
ox = round(cx - w / 2)
|
||||
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
|
||||
# ascender/descender box — the word reads centered that way.
|
||||
pad = STROKE / 2 * unit_px + 1.5
|
||||
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
|
||||
oy = round(cy - band_mid)
|
||||
for y in range(h):
|
||||
ty = y + oy
|
||||
if not 0 <= ty < self.h:
|
||||
continue
|
||||
for x in range(w):
|
||||
a = alpha[y * w + x]
|
||||
if a:
|
||||
tx = x + ox
|
||||
if 0 <= tx < self.w:
|
||||
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
|
||||
|
||||
def round_corners(self, radius):
|
||||
"""Multiply alpha with a rounded-rect mask (icon)."""
|
||||
for y in range(self.h):
|
||||
for x in range(self.w):
|
||||
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
|
||||
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
|
||||
if dx > 0 and dy > 0:
|
||||
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
|
||||
i = (y * self.w + x) * 4
|
||||
self.buf[i + 3] = round(self.buf[i + 3] * cov)
|
||||
|
||||
def png(self):
|
||||
def chunk(tag, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ tag
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
|
||||
raw = b"".join(
|
||||
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
|
||||
)
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
def save(name, canvas):
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
out = OUT / name
|
||||
out.write_bytes(canvas.png())
|
||||
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
|
||||
|
||||
|
||||
def main():
|
||||
# Portrait capsule: mark in the upper half, wordmark beneath.
|
||||
c = Canvas(600, 900)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(300, 340, 260, COL_DEEP, 0.35)
|
||||
c.mark(300, 340, 320)
|
||||
c.word("punktfunk", 44, 300, 640)
|
||||
save("grid.png", c)
|
||||
|
||||
# Wide capsule: mark left, wordmark right of it.
|
||||
c = Canvas(920, 430)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(230, 215, 200, COL_DEEP, 0.35)
|
||||
c.mark(230, 215, 240)
|
||||
c.word("punktfunk", 40, 620, 220)
|
||||
save("gridwide.png", c)
|
||||
|
||||
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
|
||||
c = Canvas(1920, 620)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(1500, 310, 330, COL_DEEP, 0.4)
|
||||
c.mark(1500, 310, 400)
|
||||
save("hero.png", c)
|
||||
|
||||
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
|
||||
c = Canvas(1120, 300)
|
||||
c.mark(150, 150, 240)
|
||||
c.word("punktfunk", 62, 660, 155)
|
||||
save("logo.png", c)
|
||||
|
||||
# Icon: brand tile, rounded corners, mark only.
|
||||
c = Canvas(256, 256)
|
||||
c.fill_gradient(BG_TOP, BG_BOT)
|
||||
c.glow(128, 128, 110, COL_DEEP, 0.3)
|
||||
c.mark(128, 128, 190)
|
||||
c.round_corners(36)
|
||||
save("icon.png", c)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -26,8 +26,12 @@ cp main.py plugin.json package.json LICENSE "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
|
||||
# Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/).
|
||||
cp assets/*.png "$DEST/assets/"
|
||||
# The Steam Input controller layout (native touchscreen `ts_n` + gamepad passthrough) the
|
||||
# backend installs (apply_controller_config → controller_base/templates + the shortcut config).
|
||||
mkdir -p "$DEST/controller_config"
|
||||
cp controller_config/punktfunk.vdf "$DEST/controller_config/punktfunk.vdf"
|
||||
[ -f decky.pyi ] && cp decky.pyi "$DEST/"
|
||||
[ -f README.md ] && cp README.md "$DEST/"
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str
|
||||
);
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual
|
||||
// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts
|
||||
// use the same name → the same lowercase configset key), so one call covers both.
|
||||
export const applyControllerConfig = callable<
|
||||
[name: string],
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut } from "./steam";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
@@ -196,6 +197,10 @@ const QamPanel: FC = () => {
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
void ensureGamepadUiShortcut();
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
||||
// Launch Punktfunk as Steam games so gamescope focuses + fullscreens them.
|
||||
//
|
||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
||||
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
||||
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
||||
// and start it with RunGame. The wrapper then execs
|
||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register non-Steam
|
||||
// shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start
|
||||
// them with RunGame. The wrapper then execs the flatpak client as a reaper descendant.
|
||||
//
|
||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||
// see applyControllerConfig):
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||
//
|
||||
// Both get the shipped artwork and the native-touch controller config.
|
||||
|
||||
import { runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -46,32 +55,33 @@ declare const collectionStore:
|
||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||
| undefined;
|
||||
|
||||
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
|
||||
// carries proper artwork and living in the library is how users relaunch their last host.
|
||||
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
|
||||
function unhideShortcut(appId: number): void {
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||
const attempt = () => {
|
||||
try {
|
||||
collectionStore?.SetAppsAsHidden?.([appId], false);
|
||||
collectionStore?.SetAppsAsHidden?.([appId], hidden);
|
||||
} catch {
|
||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||
}
|
||||
};
|
||||
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
||||
};
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||
const ART_VERSION = 2;
|
||||
function artKey(appId: number): string {
|
||||
return `punktfunk:shortcutArt:${appId}`;
|
||||
}
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once.
|
||||
const ART_VERSION = 1;
|
||||
const ART_KEY = "punktfunk:shortcutArt";
|
||||
|
||||
/**
|
||||
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
|
||||
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
|
||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||
*/
|
||||
async function applyArtwork(appId: number): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
|
||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||
return;
|
||||
}
|
||||
const art = await shortcutArt();
|
||||
@@ -89,13 +99,14 @@ async function applyArtwork(appId: number): Promise<void> {
|
||||
if (art.icon_path) {
|
||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||
}
|
||||
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
|
||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||
}
|
||||
}
|
||||
|
||||
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||
// The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
@@ -111,76 +122,128 @@ function gameIdFromAppId(appId: number): string {
|
||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||
}
|
||||
|
||||
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
||||
// library (the appId is stable for the life of the shortcut).
|
||||
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
||||
// Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the
|
||||
// library (an appId is stable for the life of the shortcut). The STREAM key is the historical
|
||||
// one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the
|
||||
// visible gamepad-UI shortcut is created alongside.
|
||||
const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId";
|
||||
const STORAGE_KEY_UI = "punktfunk:uiAppId";
|
||||
|
||||
function rememberAppId(appId: number) {
|
||||
function remember(key: string, appId: number) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(appId));
|
||||
localStorage.setItem(key, String(appId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
function recallAppId(): number | null {
|
||||
function recall(key: string): number | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
const v = localStorage.getItem(key);
|
||||
return v ? Number(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Install the native-touch controller config once per plugin session (idempotent file writes in
|
||||
// the root backend). Keyed by the shared shortcut NAME, so this single call covers both
|
||||
// shortcuts. Gated in localStorage so we don't rewrite Steam's config dir on every launch; bump
|
||||
// CONFIG_VERSION to force a reinstall after the shipped .vdf changes.
|
||||
const CONFIG_KEY = "punktfunk:controllerConfig";
|
||||
const CONFIG_VERSION = 1;
|
||||
async function ensureControllerConfig(): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(CONFIG_KEY) === `${CONFIG_VERSION}`) {
|
||||
return;
|
||||
}
|
||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||
if (r?.ok) {
|
||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||
} else {
|
||||
console.warn("punktfunk: controller config not fully applied", r);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: controller config not applied", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||
* appended per-launch via the launch options), branded and visible in the library, and
|
||||
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
||||
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
|
||||
* the script directly, and pre-0.7 shortcuts were hidden and artless.
|
||||
* Ensure the STREAM shortcut (hidden, stateful) — the per-session launcher whose launch options
|
||||
* are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is
|
||||
* an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId +
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
const remembered = recallAppId();
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null) {
|
||||
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
unhideShortcut(remembered); // pre-0.7 installs hid it
|
||||
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
unhideShortcut(appId);
|
||||
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
||||
rememberAppId(appId);
|
||||
setShortcutHidden(appId, true);
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
* Fixed launch options (no per-session state), branded, artworked, native-touch config applied,
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
function disableSteamInputForShortcut(appId: number): void {
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
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 */
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
return null;
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
return appId;
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: gamepad-UI shortcut not ensured", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
export async function launchGamepadUi(): Promise<void> {
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,9 +273,9 @@ export function isSafeLaunchId(id: string): boolean {
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into the gamepad library launcher). Encodes the target into the
|
||||
* shortcut's launch options (so one generic shortcut serves every host and every pinned
|
||||
* game), then RunGame.
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
@@ -224,10 +287,7 @@ export async function launchStream(
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const { appId, runner } = 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 { appId, runner } = await ensureStreamShortcut();
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
if (opts.browse) {
|
||||
@@ -251,7 +311,7 @@ export async function launchStream(
|
||||
|
||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||
export function stopStream(): void {
|
||||
const appId = recallAppId();
|
||||
const appId = recall(STORAGE_KEY_STREAM);
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
@@ -324,10 +324,12 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic tries VAAPI hardware decode, then software",
|
||||
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||
Vulkan Video on NVIDIA), falling back to software",
|
||||
&[
|
||||
"Automatic (VAAPI → software)",
|
||||
"Hardware (VAAPI)",
|
||||
"Automatic (hardware → software)",
|
||||
"Vulkan Video",
|
||||
"VAAPI",
|
||||
"Software",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
//! exits without connecting.
|
||||
//!
|
||||
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
|
||||
//! [--rebitrate KBPS:SECS]
|
||||
//! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
|
||||
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
|
||||
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
|
||||
@@ -51,8 +52,8 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||
Reconfigured, RequestKeyframe, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, Hello, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -84,6 +85,11 @@ struct Args {
|
||||
pin: Option<[u8; 32]>,
|
||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||
remode: Option<(Mode, u32)>,
|
||||
/// `--rebitrate KBPS:SECS` — send a mid-stream [`SetBitrate`] (the adaptive-bitrate control
|
||||
/// message) SECS seconds into the stream: the headless validator for the host's in-place
|
||||
/// encoder rate retarget (Phase 3.2) / rebuild fallback. Wiggles the cursor around the switch
|
||||
/// so a damage-driven idle desktop actually publishes frames through it.
|
||||
rebitrate: Option<(u32, u32)>,
|
||||
/// `--pair PIN` — run the pairing ceremony instead of a session.
|
||||
pair: Option<String>,
|
||||
/// `--name LABEL` — how the host labels this client when pairing.
|
||||
@@ -201,6 +207,10 @@ fn parse_args() -> Args {
|
||||
let (m, secs) = s.split_once(':')?;
|
||||
Some((parse_mode(m)?, secs.parse().ok()?))
|
||||
});
|
||||
let rebitrate = get("--rebitrate").and_then(|s| {
|
||||
let (kbps, secs) = s.split_once(':')?;
|
||||
Some((kbps.parse().ok()?, secs.parse().ok()?))
|
||||
});
|
||||
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
|
||||
// (the user asked for verification; fail closed).
|
||||
let pin = match get("--pin") {
|
||||
@@ -252,6 +262,7 @@ fn parse_args() -> Args {
|
||||
seconds: get("--seconds").and_then(|s| s.parse().ok()),
|
||||
pin,
|
||||
remode,
|
||||
rebitrate,
|
||||
pair: get("--pair").map(String::from),
|
||||
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
||||
compositor,
|
||||
@@ -470,7 +481,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
video_caps: {
|
||||
// Always ask for per-AU host timings (0xCF) — this is a measurement tool, and the
|
||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING;
|
||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||
}
|
||||
@@ -626,6 +640,64 @@ async fn session(args: Args) -> Result<()> {
|
||||
other => tracing::error!(?other, "bad Reconfigured"),
|
||||
}
|
||||
});
|
||||
} else if let Some((new_kbps, after_secs)) = args.rebitrate {
|
||||
// Mid-stream adaptive-bitrate test: after a delay, send the SetBitrate the Automatic
|
||||
// controller would and await the host's BitrateChanged ack. Host-side this exercises the
|
||||
// in-place `reconfigure_bitrate` (no IDR) or the rebuild fallback — the host log says
|
||||
// which. The cursor wiggle keeps a damage-driven idle desktop publishing frames through
|
||||
// the whole window: the encode loop only drains bitrate requests between frames, and the
|
||||
// post-switch AUs are what prove the stream carried on.
|
||||
let mut rs = send;
|
||||
let mut rr = recv;
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
let wiggle = |i: u32| InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: if i % 2 == 0 { 2 } else { -2 },
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
let end =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64 + 6);
|
||||
let switch_at =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(after_secs as u64);
|
||||
let mut sent = false;
|
||||
let mut i = 0u32;
|
||||
while std::time::Instant::now() < end {
|
||||
let _ = conn2.send_datagram(wiggle(i).encode().to_vec().into());
|
||||
i += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !sent && std::time::Instant::now() >= switch_at {
|
||||
sent = true;
|
||||
tracing::info!(new_kbps, "requesting mid-stream bitrate change");
|
||||
if io::write_msg(
|
||||
&mut rs,
|
||||
&SetBitrate {
|
||||
bitrate_kbps: new_kbps,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::error!("SetBitrate write failed");
|
||||
return;
|
||||
}
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| BitrateChanged::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) => tracing::info!(
|
||||
applied_kbps = ack.bitrate_kbps,
|
||||
"BITRATE CHANGE acked by host"
|
||||
),
|
||||
other => tracing::error!(?other, "bad BitrateChanged"),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if let Some((target_kbps, duration_ms)) = args.speed_test {
|
||||
// Bandwidth probe: after the stream warms up, ask the host to burst FLAG_PROBE filler; measure
|
||||
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
|
||||
@@ -639,11 +711,28 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let conn2 = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||
// Warm up the stream — and generate desktop activity while doing so. Damage-driven
|
||||
// capture paths (Windows IDD-push, a static headless desktop anywhere) publish NO
|
||||
// frame until something composes, and the host's pipeline build waits for a first
|
||||
// frame — so an idle virtual display would time the whole speed test out. A ±2 px
|
||||
// cursor wiggle over the wire is injected host-side into the right session/desktop.
|
||||
for i in 0..20u32 {
|
||||
let mv = InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: if i % 2 == 0 { 2 } else { -2 },
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
let _ = conn2.send_datagram(mv.encode().to_vec().into());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||
let base_pkts = rxp.load(Relaxed);
|
||||
let base_bytes = rxb.load(Relaxed);
|
||||
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||
@@ -668,6 +757,15 @@ async fn session(args: Args) -> Result<()> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// A declined burst comes back all-zero (duration_ms = 0) — e.g. the host predates
|
||||
// speed tests. Say so instead of dividing a settle-window sliver by 1 ms.
|
||||
if res.duration_ms == 0 {
|
||||
tracing::error!(
|
||||
"SPEED TEST declined by host (all-zero ProbeResult) — host too old, or it \
|
||||
rejected the request; check the host log"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The reliable result can beat the last UDP shards — let the tail arrive before reading.
|
||||
// Keep this short: video resumes the instant the burst ends, so a long settle counts
|
||||
// resumed-video packets against the probe (inflating recv past the host's wire count).
|
||||
@@ -1150,7 +1248,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
let cap_secs = args.seconds.unwrap_or(120);
|
||||
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
||||
let mut last_loss_report = std::time::Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
let (mut last_recovered, mut last_late, mut last_received, mut last_dropped) =
|
||||
(0u64, 0u64, 0u64, 0u64);
|
||||
loop {
|
||||
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
|
||||
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
|
||||
@@ -1164,6 +1263,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
lp_dt.store(
|
||||
window_loss_ppm(
|
||||
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
s.fec_late_shards.wrapping_sub(last_late),
|
||||
s.packets_received.wrapping_sub(last_received),
|
||||
s.frames_dropped.wrapping_sub(last_dropped),
|
||||
),
|
||||
@@ -1171,6 +1271,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
);
|
||||
last_loss_report = std::time::Instant::now();
|
||||
last_recovered = s.fec_recovered_shards;
|
||||
last_late = s.fec_late_shards;
|
||||
last_received = s.packets_received;
|
||||
last_dropped = s.frames_dropped;
|
||||
}
|
||||
@@ -1242,6 +1343,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
s.flush().ok();
|
||||
}
|
||||
|
||||
// PUNKTFUNK_PERF: cumulative receive-path stage split for the whole run — where the
|
||||
// receive core's time went (kernel drain vs AES-GCM open vs reassembly+FEC). This is
|
||||
// the measurement tool's view of the client-pump wall the 2026-07-14 sweeps pinned.
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
tracing::info!(
|
||||
recv_ms = p.recv_ns / 1_000_000,
|
||||
decrypt_ms = p.decrypt_ns / 1_000_000,
|
||||
reasm_ms = p.reasm_ns / 1_000_000,
|
||||
packets = p.packets,
|
||||
pkts_per_batch = p.packets.checked_div(p.batches.max(1)).unwrap_or(0),
|
||||
decrypt_ns_pkt = per_pkt(p.decrypt_ns),
|
||||
reasm_ns_pkt = per_pkt(p.reasm_ns),
|
||||
"receive stage split (whole run, PUNKTFUNK_PERF)"
|
||||
);
|
||||
}
|
||||
|
||||
latencies_us.sort_unstable();
|
||||
let pct = |p: f64| -> u64 {
|
||||
if latencies_us.is_empty() {
|
||||
|
||||
@@ -29,6 +29,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A request-access connect awaiting the operator's approval on the host: stamped by the
|
||||
/// launch handler and consumed by `on_connected`, which persists the host as paired.
|
||||
struct PendingApproval {
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: String,
|
||||
}
|
||||
|
||||
pub fn run(target: Option<&str>) -> u8 {
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
@@ -128,6 +137,14 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
|
||||
// so the next connect is an ordinary one. `None` for every normal launch, so `on_connected`
|
||||
// then only touches last-used.
|
||||
let pending_approval: Arc<Mutex<Option<PendingApproval>>> = Arc::new(Mutex::new(None));
|
||||
let pending_cb = pending_approval.clone();
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: window_label.map_or_else(
|
||||
|| "Punktfunk".to_string(),
|
||||
@@ -142,8 +159,16 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
json_status,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::touch_last_used(&fp_hex);
|
||||
// A request-access connect just succeeded → the operator approved us. Save the
|
||||
// host as paired (it was unsaved/discovered), keyed to the fingerprint we pinned.
|
||||
if let Some(p) = pending_cb.lock().unwrap().take() {
|
||||
if p.fp_hex == fp_hex {
|
||||
trust::persist_host(&p.name, &p.addr, p.port, &fp_hex, true);
|
||||
}
|
||||
}
|
||||
})),
|
||||
overlay: Some(Box::new(overlay)),
|
||||
window_size: crate::session_main::window_size(&settings_at_start),
|
||||
@@ -161,21 +186,23 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
fp_hex,
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// The console only offers Connect on paired rows; a pinless
|
||||
// launch is a logic slip, never a silent TOFU.
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
// a pinless launch is a logic slip, never a silent TOFU.
|
||||
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
||||
return ActionOutcome::Handled;
|
||||
};
|
||||
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"),
|
||||
tracing::info!(%addr, %title, request_access,
|
||||
launch = launch.as_deref().unwrap_or("desktop"),
|
||||
"launching from the console");
|
||||
// Settings re-load per launch: the console's own settings screen
|
||||
// may have changed them since the last stream.
|
||||
let settings = trust::Settings::load();
|
||||
ActionOutcome::Start(Box::new(session_params(
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
addr,
|
||||
addr.clone(),
|
||||
port,
|
||||
pin,
|
||||
identity.clone(),
|
||||
@@ -184,7 +211,20 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)))
|
||||
);
|
||||
if request_access {
|
||||
// The host PARKS the connect until the operator approves — outlast its
|
||||
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
|
||||
// shells' 185 s. On success `on_connected` persists the host as paired.
|
||||
params.connect_timeout = Duration::from_secs(185);
|
||||
*pending_approval.lock().unwrap() = Some(PendingApproval {
|
||||
name: title.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
});
|
||||
}
|
||||
ActionOutcome::Start(Box::new(params))
|
||||
}
|
||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||
OverlayAction::Quit => ActionOutcome::Quit,
|
||||
|
||||
@@ -264,9 +264,12 @@ impl PadInfo {
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualSenseEdge => "DualSense Edge",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
GamepadPref::SwitchPro => "Switch Pro",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -297,6 +300,9 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
// A paired Joy-Con set exposes the full Pro button surface through SDL, so it rides
|
||||
// the same virtual pad; single Joy-Cons stay on the Xbox 360 fallback (half a pad).
|
||||
T::NintendoSwitchPro | T::NintendoSwitchJoyconPair => GamepadPref::SwitchPro,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -778,11 +784,20 @@ impl Worker {
|
||||
self.subsystem.product_for_id(jid).unwrap_or(0),
|
||||
);
|
||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||
// 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 vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
// VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads +
|
||||
// the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142.
|
||||
if vid == 0x28DE && pid == 0x1205 {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamController;
|
||||
}
|
||||
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
|
||||
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
|
||||
// slots instead of the fold/drop policy.
|
||||
if vid == 0x054C && pid == 0x0DF2 {
|
||||
pref = GamepadPref::DualSenseEdge;
|
||||
}
|
||||
let name = self
|
||||
.subsystem
|
||||
.name_for_id(jid)
|
||||
@@ -1556,7 +1571,12 @@ impl Worker {
|
||||
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
|
||||
continue;
|
||||
};
|
||||
let is_ds = slot.pref == GamepadPref::DualSense;
|
||||
// A physical Edge takes the same raw DS5 effects packets (SDL's DS5EffectsState_t
|
||||
// layout is shared; SDL keys the enhanced path off the Edge PID itself).
|
||||
let is_ds = matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
);
|
||||
match hid {
|
||||
HidOutput::Led { r, g, b, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||
//!
|
||||
//! Three backends, picked at session start (auto: vulkan → vaapi → software;
|
||||
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
||||
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||
//!
|
||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||
@@ -384,8 +386,11 @@ impl Decoder {
|
||||
/// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's
|
||||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto
|
||||
/// (Vulkan → VAAPI → software; no VAAPI on Windows).
|
||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
||||
/// Auto's hardware order on Linux depends on the device
|
||||
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
|
||||
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
|
||||
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
|
||||
pub fn new(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
pref: &str,
|
||||
@@ -405,6 +410,31 @@ impl Decoder {
|
||||
want_keyframe: false,
|
||||
})
|
||||
};
|
||||
// Linux `auto`: try VAAPI FIRST unless this device is one where Vulkan Video is
|
||||
// the established right answer (NVIDIA — no usable VAAPI; VanGogh — VAAPI
|
||||
// chroma-fringes). Mesa now exposes decode queues by default (and the session
|
||||
// binary opts RADV in for the Deck's sake), which silently moved every desktop
|
||||
// AMD/Intel box onto FFmpeg-Vulkan-on-Mesa — user-reported to judder/error-streak
|
||||
// (then demote to software) where explicit VAAPI streams perfectly.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut vaapi_tried = false;
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||||
&& !vk
|
||||
.filter(|v| v.video_decode)
|
||||
.is_some_and(|v| v.prefer_vulkan_over_vaapi())
|
||||
{
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(reason = %e, "VAAPI unavailable — trying Vulkan Video");
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
||||
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||||
@@ -423,7 +453,7 @@ impl Decoder {
|
||||
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
||||
}
|
||||
tracing::info!(reason = %format!("{e:#}"),
|
||||
"Vulkan Video unavailable — trying VAAPI");
|
||||
"Vulkan Video unavailable — falling back");
|
||||
}
|
||||
},
|
||||
None if choice == "vulkan" => {
|
||||
@@ -435,12 +465,13 @@ impl Decoder {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Deck note: `auto` reaches VAAPI when Vulkan Video isn't available. A presenter
|
||||
// that can't display the dmabufs demotes this decoder to software mid-session
|
||||
// via [`Decoder::force_software`]. Windows has no VAAPI — auto falls straight
|
||||
// through to software there.
|
||||
// Deck/NVIDIA note: `auto` reaches VAAPI here when Vulkan Video isn't available
|
||||
// (on desktop Mesa it was already tried above — `vaapi_tried` skips the repeat).
|
||||
// A presenter that can't display the dmabufs demotes this decoder to software
|
||||
// mid-session via [`Decoder::force_software`]. Windows has no VAAPI — auto falls
|
||||
// straight through to software there.
|
||||
#[cfg(target_os = "linux")]
|
||||
if choice != "software" && choice != "vulkan" {
|
||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
@@ -558,6 +589,24 @@ impl Decoder {
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
// A failing Vulkan backend still has a hardware rung below it on
|
||||
// Linux — demote to VAAPI first (user-reported: FFmpeg-Vulkan-on-Mesa
|
||||
// error-streaking where VAAPI streams perfectly); only when that
|
||||
// can't be built either does the session land on software.
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(self.backend, Backend::Vulkan(_)) {
|
||||
match VaapiDecoder::new(self.codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(va) => tracing::info!(reason = %va,
|
||||
"VAAPI unavailable for demotion — software decode"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"{which} decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
@@ -1002,6 +1051,12 @@ pub struct VulkanDecodeDevice {
|
||||
pub instance: usize,
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
||||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub vendor_id: u32,
|
||||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
||||
/// detection for [`Self::prefer_vulkan_over_vaapi`].
|
||||
pub device_name: String,
|
||||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||
pub graphics_qf: u32,
|
||||
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
||||
@@ -1035,6 +1090,27 @@ pub struct VulkanDecodeDevice {
|
||||
pub queue_lock: std::sync::Arc<QueueLock>,
|
||||
}
|
||||
|
||||
impl VulkanDecodeDevice {
|
||||
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device?
|
||||
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
||||
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
||||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||||
/// additionally shows chroma fringing; the session binary opts RADV into
|
||||
/// `video_decode` precisely to get the Vulkan path. Vulkan-first is safe here
|
||||
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
||||
///
|
||||
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
|
||||
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
|
||||
/// Linux client uses there.
|
||||
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||
const VENDOR_AMD: u32 = 0x1002;
|
||||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||||
}
|
||||
}
|
||||
|
||||
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
@@ -1505,6 +1581,51 @@ unsafe extern "C" fn pick_vulkan(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode_device(vendor_id: u32, device_name: &str) -> VulkanDecodeDevice {
|
||||
VulkanDecodeDevice {
|
||||
get_instance_proc_addr: 0,
|
||||
instance: 0,
|
||||
physical_device: 0,
|
||||
device: 0,
|
||||
vendor_id,
|
||||
device_name: device_name.into(),
|
||||
graphics_qf: 0,
|
||||
graphics_queue_flags: 0,
|
||||
decode_qf: 0,
|
||||
decode_video_caps: 0,
|
||||
instance_extensions: Vec::new(),
|
||||
device_extensions: Vec::new(),
|
||||
f_sampler_ycbcr: true,
|
||||
f_timeline_semaphore: true,
|
||||
f_synchronization2: true,
|
||||
video_decode: true,
|
||||
d3d11_import: false,
|
||||
adapter_luid: None,
|
||||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto's Linux hardware order: Vulkan-first on NVIDIA (no usable VAAPI) and ALL AMD
|
||||
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
|
||||
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
|
||||
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
|
||||
/// Vulkan-first can never strand a box on software decode.
|
||||
#[test]
|
||||
fn vulkan_over_vaapi_on_nvidia_and_amd() {
|
||||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
|
||||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_over_vaapi()
|
||||
);
|
||||
assert!(
|
||||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
|
||||
.prefer_vulkan_over_vaapi()
|
||||
);
|
||||
}
|
||||
|
||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
|
||||
@@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle {
|
||||
impl GlyphStyle {
|
||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||
match pref {
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
||||
GlyphStyle::Shapes
|
||||
}
|
||||
Some(_) => GlyphStyle::Letters,
|
||||
None => GlyphStyle::Keyboard,
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ pub(crate) struct ConnectIntent {
|
||||
pub launch: Option<String>,
|
||||
/// What the connecting card says (host or game title).
|
||||
pub title: String,
|
||||
/// The no-PIN delegated-approval connect (the pair screen's "Request access"): the
|
||||
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||
/// binary parks on a long budget and persists the host as paired once let in.
|
||||
pub request_access: bool,
|
||||
}
|
||||
|
||||
pub(crate) enum Nav {
|
||||
|
||||
@@ -100,6 +100,7 @@ impl HomeScreen {
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
request_access: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ impl LibraryScreen {
|
||||
fp_hex: self.fp_hex.clone(),
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
request_access: false,
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, ERROR, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
@@ -18,10 +18,24 @@ enum Field {
|
||||
Device,
|
||||
}
|
||||
|
||||
/// The ordered actions a pair screen presents. `RequestAccess` leads only when the host
|
||||
/// has an advertised fingerprint to pin (a discovered host); a manually-typed host with
|
||||
/// no advert is PIN-only, exactly like the desktop shells.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Role {
|
||||
RequestAccess,
|
||||
Pin,
|
||||
Device,
|
||||
Pair,
|
||||
}
|
||||
|
||||
pub(crate) struct PairScreen {
|
||||
host_name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
/// The host's advertised certificate fingerprint (lowercase hex); empty = a manual
|
||||
/// entry with no advert → no request-access path.
|
||||
fp_hex: String,
|
||||
list: MenuList,
|
||||
keyboard: Keyboard,
|
||||
pin: String,
|
||||
@@ -39,6 +53,7 @@ impl PairScreen {
|
||||
host_name: host.name.clone(),
|
||||
addr: host.addr.clone(),
|
||||
port: host.port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
list: MenuList::new(),
|
||||
keyboard: Keyboard::new(),
|
||||
pin: String::new(),
|
||||
@@ -49,6 +64,25 @@ impl PairScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the no-PIN "request access" action is offered (host advertises an identity
|
||||
/// to pin). Stable across `busy` so the row list never reshuffles mid-ceremony.
|
||||
fn can_request(&self) -> bool {
|
||||
!self.fp_hex.is_empty()
|
||||
}
|
||||
|
||||
/// The ordered roles for the current host — the single source both `rows` (render) and
|
||||
/// `menu` (activate dispatch) index, so a cursor never acts on a stale row.
|
||||
fn roles(&self) -> Vec<Role> {
|
||||
let mut roles = Vec::with_capacity(4);
|
||||
if self.can_request() {
|
||||
roles.push(Role::RequestAccess);
|
||||
}
|
||||
roles.push(Role::Pin);
|
||||
roles.push(Role::Device);
|
||||
roles.push(Role::Pair);
|
||||
roles
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
}
|
||||
@@ -170,13 +204,29 @@ impl PairScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, 3);
|
||||
let roles = self.roles();
|
||||
let (msg, pulse) = self.list.menu(ev, roles.len());
|
||||
match msg {
|
||||
ListMsg::Activate => {
|
||||
match self.list.cursor {
|
||||
0 => self.editing = Some(Field::Pin),
|
||||
1 => self.editing = Some(Field::Device),
|
||||
_ if self.can_pair() => {
|
||||
match roles.get(self.list.cursor) {
|
||||
Some(Role::RequestAccess) if !self.busy => {
|
||||
// The no-PIN path: connect and park until the operator approves this
|
||||
// device on the host. The shell shows the approval takeover; on
|
||||
// success the binary persists the host as paired. Leave the pair
|
||||
// screen so a canceled or finished session returns to Home.
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: self.addr.clone(),
|
||||
port: self.port,
|
||||
fp_hex: self.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: self.host_name.clone(),
|
||||
request_access: true,
|
||||
});
|
||||
fx.pop();
|
||||
}
|
||||
Some(Role::Pin) => self.editing = Some(Field::Pin),
|
||||
Some(Role::Device) => self.editing = Some(Field::Device),
|
||||
Some(Role::Pair) if self.can_pair() => {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
fx.cmds.push(ConsoleCmd::Pair {
|
||||
@@ -191,8 +241,11 @@ impl PairScreen {
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// No PIN yet — jump into the PIN field instead of a dead press.
|
||||
self.list.cursor = 0;
|
||||
// Pair with no PIN yet (or a request while busy) — jump into the
|
||||
// PIN field instead of a dead press.
|
||||
if let Some(i) = roles.iter().position(|r| *r == Role::Pin) {
|
||||
self.list.cursor = i;
|
||||
}
|
||||
self.editing = Some(Field::Pin);
|
||||
}
|
||||
}
|
||||
@@ -233,9 +286,14 @@ impl PairScreen {
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
let intro = if self.can_request() {
|
||||
"Request access and approve this device on the host, or enter the PIN it shows."
|
||||
} else {
|
||||
"Enter the PIN from the host's web console (Pairing page) or its log."
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Enter the PIN from the host's web console (Pairing page) or its log.",
|
||||
intro,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
@@ -317,19 +375,39 @@ impl PairScreen {
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string();
|
||||
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
|
||||
pin.caret = self.editing == Some(Field::Pin);
|
||||
let mut device = RowSpec::field(
|
||||
"Device name",
|
||||
self.device.clone(),
|
||||
"How the host lists this device",
|
||||
);
|
||||
device.caret = self.editing == Some(Field::Device);
|
||||
vec![
|
||||
pin,
|
||||
device,
|
||||
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
|
||||
]
|
||||
let has_request = self.can_request();
|
||||
self.roles()
|
||||
.into_iter()
|
||||
.map(|role| match role {
|
||||
Role::RequestAccess => {
|
||||
let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
|
||||
r.header = Some("No PIN needed");
|
||||
r
|
||||
}
|
||||
Role::Pin => {
|
||||
let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
|
||||
pin.caret = self.editing == Some(Field::Pin);
|
||||
// When a request-access path is offered above, head the PIN group so
|
||||
// the two ways to pair read as alternatives.
|
||||
if has_request {
|
||||
pin.header = Some("Or pair with a PIN");
|
||||
}
|
||||
pin
|
||||
}
|
||||
Role::Device => {
|
||||
let mut device = RowSpec::field(
|
||||
"Device name",
|
||||
self.device.clone(),
|
||||
"How the host lists this device",
|
||||
);
|
||||
device.caret = self.editing == Some(Field::Device);
|
||||
device
|
||||
}
|
||||
Role::Pair => {
|
||||
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +466,43 @@ mod tests {
|
||||
assert!(fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// A host with an advertised fingerprint offers Request Access as the first row; A on
|
||||
/// it raises a request-access connect intent (pinning the advert) and leaves the screen.
|
||||
#[test]
|
||||
fn request_access_connects_and_leaves() {
|
||||
let mut host = host();
|
||||
host.fp_hex = "abcd".into();
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "deck",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PairScreen::new(&host, "deck");
|
||||
assert_eq!(s.roles().len(), 4, "Request Access + PIN + Device + Pair");
|
||||
s.list.cursor = 0; // the Request Access row leads
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
let intent = fx.connect.expect("request-access raises a connect intent");
|
||||
assert!(intent.request_access);
|
||||
assert_eq!(intent.fp_hex, "abcd");
|
||||
assert!(matches!(fx.nav, Some(crate::screens::Nav::Pop)));
|
||||
}
|
||||
|
||||
/// A manual host (no advert) is PIN-only — the Request Access row never appears.
|
||||
#[test]
|
||||
fn no_request_access_without_an_advert() {
|
||||
let s = PairScreen::new(&host(), "deck"); // host() has an empty fp_hex
|
||||
assert!(!s.can_request());
|
||||
assert_eq!(s.roles().len(), 3, "PIN + Device + Pair only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_is_digits_only() {
|
||||
let mut s = PairScreen::new(&host(), "d");
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
@@ -28,10 +28,11 @@ enum RowId {
|
||||
Mic,
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
Stats,
|
||||
}
|
||||
|
||||
const ROWS: [RowId; 12] = [
|
||||
const ROWS: [RowId; 13] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::Bitrate,
|
||||
@@ -43,6 +44,7 @@ const ROWS: [RowId; 12] = [
|
||||
RowId::Mic,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
RowId::Stats,
|
||||
];
|
||||
|
||||
@@ -241,6 +243,11 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
"Controller type",
|
||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||
),
|
||||
RowId::Touch => (
|
||||
Some("Touchscreen"),
|
||||
"Touch mode",
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
@@ -278,6 +285,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::Touch => {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -348,6 +359,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||
}
|
||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::Stats => {
|
||||
let cur = StatsVerbosity::ALL
|
||||
.iter()
|
||||
@@ -462,6 +478,35 @@ mod tests {
|
||||
assert!(!ctx.settings.mic_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.touch_mode, "trackpad");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Trackpad → Pointer → Touch, then a step past the end is a boundary.
|
||||
assert!(
|
||||
!adjust(RowId::Touch, -1, false, &mut ctx),
|
||||
"already first = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.touch_mode, "pointer");
|
||||
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.touch_mode, "touch");
|
||||
assert!(!adjust(RowId::Touch, 1, false, &mut ctx), "last = thud");
|
||||
// A wraps back to the first.
|
||||
assert!(adjust(RowId::Touch, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
|
||||
@@ -45,6 +45,9 @@ struct Connecting {
|
||||
title: String,
|
||||
canceling: bool,
|
||||
appear: f64,
|
||||
/// A request-access wait (parked on the host until the operator approves) — the
|
||||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||
request_access: bool,
|
||||
}
|
||||
|
||||
/// What the session binary hands the shell at construction.
|
||||
@@ -152,6 +155,7 @@ impl Shell {
|
||||
title,
|
||||
canceling: false,
|
||||
appear: 0.0,
|
||||
request_access: false,
|
||||
})
|
||||
}
|
||||
None => self.connecting = None,
|
||||
@@ -236,6 +240,7 @@ impl Shell {
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
request_access: false,
|
||||
})
|
||||
});
|
||||
self.bus.send(ConsoleCmd::CancelWake);
|
||||
@@ -254,12 +259,16 @@ impl Shell {
|
||||
|
||||
fn start_connect(&mut self, intent: ConnectIntent) {
|
||||
self.set_connecting(Some(intent.title.clone()));
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.request_access = intent.request_access;
|
||||
}
|
||||
self.actions.push_back(OverlayAction::Launch {
|
||||
addr: intent.addr,
|
||||
port: intent.port,
|
||||
fp_hex: intent.fp_hex,
|
||||
launch: intent.launch,
|
||||
title: intent.title,
|
||||
request_access: intent.request_access,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,6 +638,17 @@ impl Shell {
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else if c.request_access {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Waiting for approval…".to_string(),
|
||||
format!(
|
||||
"Approve this device in {}'s console or web UI — no PIN needed.",
|
||||
c.title
|
||||
),
|
||||
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||
))
|
||||
} else {
|
||||
Some((
|
||||
c.appear,
|
||||
|
||||
@@ -531,10 +531,18 @@ pub mod gamepad {
|
||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||
|
||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
|
||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
||||
/// codec plus the four native back/Fn button bits).
|
||||
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
||||
/// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured
|
||||
/// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by
|
||||
/// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
|
||||
/// (the wired controller interface — the N4-spike finding).
|
||||
pub const DEVTYPE_STEAMDECK: u8 = 3;
|
||||
|
||||
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
||||
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
||||
|
||||
@@ -73,6 +73,11 @@ pub enum OverlayAction {
|
||||
fp_hex: String,
|
||||
launch: Option<String>,
|
||||
title: String,
|
||||
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||||
/// open a connect the host PARKS until the operator approves this device in its
|
||||
/// console (a long connect budget), then persist it as paired. `false` = an
|
||||
/// ordinary connect to an already-paired host.
|
||||
request_access: bool,
|
||||
},
|
||||
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
|
||||
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
//!
|
||||
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
|
||||
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
|
||||
//! overlay tier.
|
||||
//! overlay tier. (The Android/Apple twins additionally map a three-finger vertical SWIPE to
|
||||
//! their local soft keyboard and gate scroll to exactly two fingers for it; SDL builds have
|
||||
//! no soft keyboard to summon, so here 2+ fingers scroll.)
|
||||
//!
|
||||
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
|
||||
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
|
||||
|
||||
@@ -661,6 +661,11 @@ impl Presenter {
|
||||
instance: instance.handle().as_raw() as usize,
|
||||
physical_device: pdev.as_raw() as usize,
|
||||
device: device.handle().as_raw() as usize,
|
||||
vendor_id: dev_props.vendor_id,
|
||||
device_name: dev_props
|
||||
.device_name_as_c_str()
|
||||
.map(|c| c.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
graphics_qf: qfi,
|
||||
graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(),
|
||||
decode_qf,
|
||||
|
||||
@@ -882,13 +882,19 @@ 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.
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||
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.
|
||||
/// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
||||
/// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
||||
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||
|
||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
@@ -945,6 +951,8 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||
//! evidence there is.
|
||||
//!
|
||||
//! AIMD shape: two consecutive bad windows ⇒ multiplicative decrease (×0.7, floored); ~10 s of
|
||||
//! clean windows ⇒ additive-ish increase (+~6 %, ceilinged at the session's starting rate — the
|
||||
//! controller recovers *back to* what was negotiated, never beyond it). Changes are rate-limited
|
||||
//! (each one costs the IDR the host's rebuilt encoder opens with) and the whole controller
|
||||
//! disables itself against a host that never answers [`crate::quic::BitrateChanged`] (an older
|
||||
//! build that ignores unknown control messages).
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, or ≥6 % loss) backs off ×0.7
|
||||
//! immediately; ordinary congestion (heavy-but-recoverable loss, an OWD rise) needs two
|
||||
//! consecutive bad windows. Recovery is two-mode: **slow start** — until the first congestion
|
||||
//! signal the rate DOUBLES each clean window (cooldown-paced), which is how an Automatic session
|
||||
//! climbs from the conservative start to the [`set_ceiling`](BitrateController::set_ceiling)
|
||||
//! measured by the startup link-capacity probe in seconds instead of minutes — then classic
|
||||
//! additive recovery (+~6 % after ~4.5 s clean, ceilinged). Changes are rate-limited (each one
|
||||
//! costs the IDR the host's rebuilt encoder opens with) and the whole controller disables itself
|
||||
//! against a host that never answers [`crate::quic::BitrateChanged`] (an older build that
|
||||
//! ignores unknown control messages).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -28,15 +32,21 @@ use std::time::{Duration, Instant};
|
||||
/// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a
|
||||
/// mis-measured window from cratering the session.
|
||||
const FLOOR_KBPS: u32 = 5_000;
|
||||
/// Consecutive bad windows before a decrease — one window can be a scheduler blip or a single
|
||||
/// Wi-Fi scan; two in a row (1.5 s) is a condition.
|
||||
/// Consecutive bad windows before an ORDINARY decrease — one window can be a scheduler blip or a
|
||||
/// single Wi-Fi scan; two in a row (1.5 s) is a condition. A SEVERE window skips the wait.
|
||||
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
||||
/// Consecutive clean windows before probing back up (~10 s at the 750 ms cadence): recovery is
|
||||
/// deliberately much slower than backoff, classic AIMD.
|
||||
const CLEAN_WINDOWS_TO_INCREASE: u32 = 13;
|
||||
/// Window shard loss at/above which ONE window is enough to back off — 6 % is past any
|
||||
/// blip/retry tail, and every 750 ms spent there is visible damage. Unrecoverable frames and
|
||||
/// jump-to-live flushes are severe for the same reason.
|
||||
const SEVERE_LOSS_PPM: u32 = 60_000;
|
||||
/// Consecutive clean windows before probing back up in congestion-avoidance mode (~4.5 s at the
|
||||
/// 750 ms cadence): recovery stays slower than backoff, classic AIMD. (Slow start ignores this —
|
||||
/// it doubles on every cooled clean window until the first congestion signal.)
|
||||
const CLEAN_WINDOWS_TO_INCREASE: u32 = 6;
|
||||
/// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR
|
||||
/// on the host, and back-to-back steps would outrun the ack/effect round trip.
|
||||
const CHANGE_COOLDOWN: Duration = Duration::from_secs(3);
|
||||
/// on the host today (in-place reconfigure is planned), and back-to-back steps would outrun the
|
||||
/// ack/effect round trip.
|
||||
const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
|
||||
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
||||
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
||||
const HEAVY_LOSS_PPM: u32 = 20_000;
|
||||
@@ -56,9 +66,14 @@ pub(crate) struct BitrateController {
|
||||
enabled: bool,
|
||||
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
||||
current_kbps: u32,
|
||||
/// The session's starting (negotiated) rate — the recovery ceiling.
|
||||
/// The climb ceiling: the negotiated start rate until the startup link-capacity probe
|
||||
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
||||
/// Automatic session scale past its conservative start.
|
||||
ceiling_kbps: u32,
|
||||
floor_kbps: u32,
|
||||
/// Slow start: true until the first congestion signal — clean windows DOUBLE the rate
|
||||
/// (cooldown-paced) instead of the +6 % additive step.
|
||||
probing: bool,
|
||||
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
||||
owd_means: VecDeque<i64>,
|
||||
bad_windows: u32,
|
||||
@@ -78,6 +93,7 @@ impl BitrateController {
|
||||
current_kbps: start_kbps,
|
||||
ceiling_kbps: start_kbps,
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
@@ -86,6 +102,17 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raise the climb ceiling to a measured link capacity (the startup speed-test probe's
|
||||
/// delivered throughput with headroom already subtracted by the caller). Without this call
|
||||
/// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers:
|
||||
/// a congested-moment measurement must not shrink authority below what was negotiated
|
||||
/// (descent is the congestion signals' job).
|
||||
pub(crate) fn set_ceiling(&mut self, kbps: u32) {
|
||||
if self.enabled && kbps > self.ceiling_kbps {
|
||||
self.ceiling_kbps = kbps;
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||
@@ -134,10 +161,16 @@ impl BitrateController {
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let bad = dropped > 0 || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || flushed;
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush) or
|
||||
// loss far past any blip — one window is enough. Ordinary congestion (heavy-but-
|
||||
// recoverable loss, an OWD rise) still needs two consecutive windows.
|
||||
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad;
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
// Any congestion signal ends slow start for good — from here on, climbs are additive.
|
||||
self.probing = false;
|
||||
} else {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
@@ -148,16 +181,27 @@ impl BitrateController {
|
||||
if !cooled {
|
||||
return None;
|
||||
}
|
||||
if self.bad_windows >= BAD_WINDOWS_TO_DECREASE && self.current_kbps > self.floor_kbps {
|
||||
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
||||
&& self.current_kbps > self.floor_kbps
|
||||
{
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
return self.request(next, now);
|
||||
}
|
||||
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE && self.current_kbps < self.ceiling_kbps
|
||||
{
|
||||
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
|
||||
self.clean_windows = 0;
|
||||
return self.request(next, now);
|
||||
if self.current_kbps < self.ceiling_kbps {
|
||||
// Slow start: double on every cooled clean window until the first congestion signal
|
||||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||
if self.probing && self.clean_windows >= 1 {
|
||||
let next = self.current_kbps.saturating_mul(2).min(self.ceiling_kbps);
|
||||
self.clean_windows = 0;
|
||||
return self.request(next, now);
|
||||
}
|
||||
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE {
|
||||
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
|
||||
self.clean_windows = 0;
|
||||
return self.request(next, now);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -204,44 +248,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_bad_windows_step_down_multiplicatively() {
|
||||
fn two_ordinary_bad_windows_step_down_multiplicatively() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
// One bad window is a blip — no reaction.
|
||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||
assert_eq!(c.on_window(ticks(start, 0), 0, 25_000, None, false), None);
|
||||
// The second consecutive bad window backs off ×0.7.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 1), 0, 25_000, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None); // bad #1 again
|
||||
assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, false), Some(9_800));
|
||||
assert_eq!(c.on_window(ticks(start, 6), 0, 25_000, None, false), None); // bad #1 again
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 0, 25_000, None, false),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severe_window_backs_off_immediately() {
|
||||
// An unrecoverable frame (the user SAW a freeze) skips the two-window wait…
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and so does a jump-to-live flush.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), Some(14_000));
|
||||
// …and ≥6 % window loss.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 80_000, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_blocks_back_to_back_steps() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Two more bad windows land INSIDE the 3 s cooldown (ticks 2,3 = 1.5/2.25 s) → held.
|
||||
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 3), 1, 0, None, false), None);
|
||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||
// boundary (tick 2 = 1.5 s) it fires.
|
||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), Some(9_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_is_never_crossed() {
|
||||
let mut c = BitrateController::new(6_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), Some(5_000));
|
||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), Some(5_000));
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
||||
@@ -252,21 +318,76 @@ mod tests {
|
||||
fn sustained_clean_recovers_toward_ceiling_only() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// 13 clean windows → one additive step up (14000 + 14000/16 + 1 = 14876).
|
||||
let up = run_clean(&mut c, start, 2, 13);
|
||||
// The backoff ended slow start → additive recovery: 6 clean windows → one +~6 % step
|
||||
// (14000 + 14000/16 + 1 = 14876).
|
||||
let up = run_clean(&mut c, start, 2, 7);
|
||||
assert_eq!(up, Some(14_876));
|
||||
c.on_ack(14_876);
|
||||
// Fully recovered → clean windows at the ceiling stay quiet (never probe past start).
|
||||
// Fully recovered → clean windows at the ceiling stay quiet (never probe past it).
|
||||
c.on_ack(20_000);
|
||||
assert_eq!(run_clean(&mut c, start, 40, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_start_doubles_to_a_probed_ceiling_then_stops() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
// The startup link-capacity probe measured ~430 Mbps delivered → ×0.7 ceiling.
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||
let mut got = Vec::new();
|
||||
for i in 0..14 {
|
||||
if let Some(k) = c.on_window(ticks(start, i), 0, 0, Some(10_000), false) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(got, vec![40_000, 80_000, 160_000, 300_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_congestion_ends_slow_start_for_good() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), false),
|
||||
Some(40_000)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// Severe window → immediate ×0.7, and slow start is over.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, Some(10_000), false),
|
||||
Some(28_000)
|
||||
);
|
||||
c.on_ack(28_000);
|
||||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||
let mut next = None;
|
||||
for i in 3..12 {
|
||||
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), false);
|
||||
if next.is_some() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(next, Some(29_751)); // 28000 + 28000/16 + 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ceiling_is_ignored_when_disabled_and_never_lowers() {
|
||||
let mut c = BitrateController::new(0);
|
||||
c.set_ceiling(1_000_000);
|
||||
assert_eq!(c.on_window(Instant::now(), 0, 0, None, false), None);
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(10_000); // below the negotiated start → ignored
|
||||
assert_eq!(c.ceiling_kbps, 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owd_rise_alone_is_a_congestion_signal() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -304,12 +425,4 @@ mod tests {
|
||||
}
|
||||
assert_eq!(sent, MAX_UNACKED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_counts_as_a_bad_window() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(c.on_window(ticks(start, 0), 0, 0, None, true), None);
|
||||
assert_eq!(c.on_window(ticks(start, 1), 0, 0, None, true), Some(14_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1934,7 +1934,15 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||
let (mut last_recovered, mut last_late, mut last_received, mut last_dropped) =
|
||||
(0u64, 0u64, 0u64, 0u64);
|
||||
// PUNKTFUNK_PERF: per-window pump observability — the Session's receive stage split
|
||||
// (recv / decrypt / reassemble+FEC, see `Session::take_pump_perf`) and completed-AU
|
||||
// inter-arrival jitter. Smoothness has no metric otherwise: jump-to-live counters only
|
||||
// fire after the stream is already seconds behind.
|
||||
let pump_perf_on = std::env::var("PUNKTFUNK_PERF").is_ok_and(|v| v != "0");
|
||||
let mut arrivals_us: Vec<u32> = Vec::new();
|
||||
let mut last_arrival: Option<Instant> = None;
|
||||
// Adaptive bitrate (see `crate::abr`): armed only when the embedder asked for Automatic
|
||||
// (`bitrate_kbps == 0`) and the host echoed the rate it actually configured (an old host
|
||||
// echoes 0 → controller stays permanently off). Fed once per report window with the same
|
||||
@@ -1945,6 +1953,22 @@ async fn worker_main(args: WorkerArgs) {
|
||||
} else {
|
||||
0
|
||||
});
|
||||
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||
// measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance)
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
|
||||
&& resolved_bitrate_kbps > 0
|
||||
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
|
||||
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
|
||||
let mut capacity_probe_deadline: Option<Instant> = None;
|
||||
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||
let mut flush_in_window = false;
|
||||
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
||||
@@ -1999,6 +2023,65 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
p.active && !p.done
|
||||
};
|
||||
// Fire the startup link-capacity probe once the stream has settled (see the constants
|
||||
// above), and fold its measurement into the ABR ceiling when the result lands.
|
||||
if let Some(at) = capacity_probe_at {
|
||||
if Instant::now() >= at {
|
||||
capacity_probe_at = None;
|
||||
*pump_probe.lock().unwrap() = ProbeState {
|
||||
active: true,
|
||||
..Default::default()
|
||||
};
|
||||
if ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps: CAPACITY_PROBE_KBPS,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
}))
|
||||
.is_ok()
|
||||
{
|
||||
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||
tracing::info!(
|
||||
target_kbps = CAPACITY_PROBE_KBPS,
|
||||
duration_ms = CAPACITY_PROBE_MS,
|
||||
"adaptive bitrate: startup link-capacity probe"
|
||||
);
|
||||
} else {
|
||||
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(deadline) = capacity_probe_deadline {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.done {
|
||||
capacity_probe_deadline = None;
|
||||
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||
/ p.host_duration_ms.max(1) as u64)
|
||||
as u32;
|
||||
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||
abr.set_ceiling(ceiling);
|
||||
tracing::info!(
|
||||
delivered_kbps,
|
||||
ceiling_kbps = ceiling,
|
||||
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
} else if Instant::now() >= deadline {
|
||||
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||
p.active = false;
|
||||
capacity_probe_deadline = None;
|
||||
tracing::info!(
|
||||
"adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
}
|
||||
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
||||
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||
@@ -2009,6 +2092,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||
let loss_ppm = window_loss_ppm(
|
||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
st.fec_late_shards.wrapping_sub(last_late),
|
||||
st.packets_received.wrapping_sub(last_received),
|
||||
window_dropped,
|
||||
);
|
||||
@@ -2035,14 +2119,58 @@ async fn worker_main(args: WorkerArgs) {
|
||||
flush_in_window = false;
|
||||
last_report = Instant::now();
|
||||
last_recovered = st.fec_recovered_shards;
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
if pump_perf_on {
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
tracing::info!(
|
||||
recv_ms = p.recv_ns / 1_000_000,
|
||||
decrypt_ms = p.decrypt_ns / 1_000_000,
|
||||
reasm_ms = p.reasm_ns / 1_000_000,
|
||||
packets = p.packets,
|
||||
batches = p.batches,
|
||||
pkts_per_batch = p.packets.checked_div(p.batches).unwrap_or(0),
|
||||
decrypt_ns_pkt = per_pkt_ns(p.decrypt_ns),
|
||||
reasm_ns_pkt = per_pkt_ns(p.reasm_ns),
|
||||
"pump stage split (window)"
|
||||
);
|
||||
}
|
||||
// Inter-arrival jitter over the window's completed AUs. `late` counts gaps
|
||||
// over 2× the window median — the "a frame arrived visibly off-beat" tally.
|
||||
if arrivals_us.len() >= 8 {
|
||||
arrivals_us.sort_unstable();
|
||||
let pct = |q: usize| arrivals_us[(arrivals_us.len() - 1) * q / 100];
|
||||
let (p50, p95) = (pct(50), pct(95));
|
||||
let late = arrivals_us.iter().filter(|&&d| d > p50 * 2).count();
|
||||
tracing::info!(
|
||||
frames = arrivals_us.len() + 1,
|
||||
arrival_p50_us = p50,
|
||||
arrival_p95_us = p95,
|
||||
arrival_max_us = arrivals_us.last().copied().unwrap_or(0),
|
||||
late,
|
||||
"frame inter-arrival jitter (window)"
|
||||
);
|
||||
}
|
||||
arrivals_us.clear();
|
||||
}
|
||||
}
|
||||
match session.poll_frame() {
|
||||
Ok(frame) => {
|
||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||
continue; // speed-test filler, not video — measured via the counters above
|
||||
}
|
||||
if pump_perf_on {
|
||||
let now = Instant::now();
|
||||
if let Some(prev) = last_arrival.replace(now) {
|
||||
// 4096 ≈ 17 s at 240 fps — a stuck window can't grow it unbounded.
|
||||
if arrivals_us.len() < 4096 {
|
||||
arrivals_us.push((now - prev).as_micros().min(u32::MAX as u128)
|
||||
as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
|
||||
// the pump consumes strictly in order at the arrival rate, so once behind, the
|
||||
// stream stays behind for good (observed live: stuck 6–7 s). Pre-decode AUs are
|
||||
|
||||
@@ -138,8 +138,8 @@ impl CompositorPref {
|
||||
/// 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`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply
|
||||
/// omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), 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).
|
||||
@@ -156,19 +156,27 @@ 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.)*
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — one stick + dual
|
||||
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
||||
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
||||
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.
|
||||
/// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips
|
||||
/// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs
|
||||
/// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows
|
||||
/// (UMDF minidriver, Steam-Input-promoted).
|
||||
SteamDeck,
|
||||
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
|
||||
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
|
||||
/// Elite P1–P4) land on a native slot instead of the fold/drop policy.
|
||||
DualSenseEdge,
|
||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
|
||||
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
|
||||
SwitchPro,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`.
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -178,6 +186,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualShock4 => 4,
|
||||
GamepadPref::SteamController => 5,
|
||||
GamepadPref::SteamDeck => 6,
|
||||
GamepadPref::DualSenseEdge => 7,
|
||||
GamepadPref::SwitchPro => 8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +201,8 @@ impl GamepadPref {
|
||||
4 => GamepadPref::DualShock4,
|
||||
5 => GamepadPref::SteamController,
|
||||
6 => GamepadPref::SteamDeck,
|
||||
7 => GamepadPref::DualSenseEdge,
|
||||
8 => GamepadPref::SwitchPro,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -208,12 +220,16 @@ impl GamepadPref {
|
||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
||||
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
||||
"dualsenseedge" | "dualsense-edge" | "edge" | "dsedge" => GamepadPref::DualSenseEdge,
|
||||
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
|
||||
GamepadPref::SwitchPro
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -223,6 +239,8 @@ impl GamepadPref {
|
||||
GamepadPref::DualShock4 => "dualshock4",
|
||||
GamepadPref::SteamController => "steamcontroller",
|
||||
GamepadPref::SteamDeck => "steamdeck",
|
||||
GamepadPref::DualSenseEdge => "dualsenseedge",
|
||||
GamepadPref::SwitchPro => "switchpro",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,23 @@
|
||||
//! shards/block — this is what removes the GameStream 255-shard / ~1 Gbps wall.
|
||||
//! Shard length must be even.
|
||||
|
||||
use super::{validate_block_shape, validate_encode_shape, ErasureCoder, FecError};
|
||||
use super::{
|
||||
validate_block_shape, validate_encode_shape, validate_into_shape, ErasureCoder, FecError,
|
||||
};
|
||||
use crate::config::FecScheme;
|
||||
use reed_solomon_simd::ReedSolomonEncoder;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct Gf16Coder;
|
||||
#[derive(Default)]
|
||||
pub struct Gf16Coder {
|
||||
/// Cached Leopard encoder (plan Phase 1.4): `reset()` re-shapes it per block while
|
||||
/// reusing its working space, so steady-state frames cost no encoder construction (the
|
||||
/// old `reed_solomon_simd::encode` convenience call built one — engine CPU-feature
|
||||
/// detection, FFT planning, work-buffer allocs — per block). `Mutex` only to keep the
|
||||
/// `&self` trait surface; a session's coder is driven by its one send thread, so the
|
||||
/// lock is uncontended.
|
||||
enc: Mutex<Option<ReedSolomonEncoder>>,
|
||||
}
|
||||
|
||||
impl ErasureCoder for Gf16Coder {
|
||||
fn scheme(&self) -> FecScheme {
|
||||
@@ -13,16 +26,62 @@ impl ErasureCoder for Gf16Coder {
|
||||
}
|
||||
|
||||
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
||||
let mut out = Vec::new();
|
||||
self.encode_into(data, recovery_count, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_into(
|
||||
&self,
|
||||
data: &[&[u8]],
|
||||
recovery_count: usize,
|
||||
out: &mut Vec<Vec<u8>>,
|
||||
) -> Result<(), FecError> {
|
||||
if recovery_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
out.clear();
|
||||
return Ok(());
|
||||
}
|
||||
validate_encode_shape(data)?;
|
||||
let k = data.len();
|
||||
if data[0].len() % 2 != 0 {
|
||||
let shard_len = data[0].len();
|
||||
if shard_len % 2 != 0 {
|
||||
return Err(FecError::Config("GF(2^16) shard length must be even"));
|
||||
}
|
||||
reed_solomon_simd::encode(k, recovery_count, data)
|
||||
.map_err(|_| FecError::Backend("gf16 encode"))
|
||||
let mut guard = self.enc.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let enc = match guard.as_mut() {
|
||||
Some(enc) => {
|
||||
enc.reset(k, recovery_count, shard_len)
|
||||
.map_err(|_| FecError::Backend("gf16 encoder reset"))?;
|
||||
enc
|
||||
}
|
||||
None => guard.insert(
|
||||
ReedSolomonEncoder::new(k, recovery_count, shard_len)
|
||||
.map_err(|_| FecError::Backend("gf16 encoder init"))?,
|
||||
),
|
||||
};
|
||||
for shard in data {
|
||||
enc.add_original_shard(shard)
|
||||
.map_err(|_| FecError::Backend("gf16 add shard"))?;
|
||||
}
|
||||
let result = enc.encode().map_err(|_| FecError::Backend("gf16 encode"))?;
|
||||
// Copy the parity into the caller's pooled buffers: existing `Vec`s are reused
|
||||
// (clear keeps capacity), the pool grows once to the session's high-water M.
|
||||
out.truncate(recovery_count);
|
||||
let mut parity = result.recovery_iter();
|
||||
for buf in out.iter_mut() {
|
||||
let shard = parity
|
||||
.next()
|
||||
.ok_or(FecError::Backend("gf16 parity count"))?;
|
||||
buf.clear();
|
||||
buf.extend_from_slice(shard);
|
||||
}
|
||||
for shard in parity {
|
||||
out.push(shard.to_vec());
|
||||
}
|
||||
if out.len() != recovery_count {
|
||||
return Err(FecError::Backend("gf16 parity count"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -81,4 +140,46 @@ impl ErasureCoder for Gf16Coder {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn reconstruct_into(
|
||||
&self,
|
||||
recovery_count: usize,
|
||||
data: &mut [&mut [u8]],
|
||||
have: &[bool],
|
||||
recovery: &[(usize, &[u8])],
|
||||
) -> Result<(), FecError> {
|
||||
validate_into_shape(data, have, recovery, recovery_count)?;
|
||||
if have.iter().all(|h| *h) {
|
||||
return Ok(()); // nothing missing — no codec work, no copies
|
||||
}
|
||||
if data[0].len() % 2 != 0 {
|
||||
return Err(FecError::Config("GF(2^16) shard length must be even"));
|
||||
}
|
||||
let data_count = data.len();
|
||||
// Present originals as indexed refs (shared reborrows of the caller's slots); the decoder
|
||||
// returns the restored shards owned, so the borrows end before the write-back below.
|
||||
let original_in: Vec<(usize, &[u8])> = data
|
||||
.iter()
|
||||
.zip(have)
|
||||
.enumerate()
|
||||
.filter(|(_, (_, &h))| h)
|
||||
.map(|(i, (s, _))| (i, &**s))
|
||||
.collect();
|
||||
let restored = reed_solomon_simd::decode(
|
||||
data_count,
|
||||
recovery_count,
|
||||
original_in,
|
||||
recovery.iter().copied(),
|
||||
)
|
||||
.map_err(|_| FecError::Backend("gf16 decode"))?;
|
||||
for (i, h) in have.iter().enumerate() {
|
||||
if !*h {
|
||||
let shard = restored
|
||||
.get(&i)
|
||||
.ok_or(FecError::Backend("gf16 decode left an original missing"))?;
|
||||
data[i].copy_from_slice(shard);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@
|
||||
//! client (unlike Vandermonde RS, whose parity is not interoperable). Hard ceiling: data +
|
||||
//! recovery ≤ 255 shards/block.
|
||||
|
||||
use super::{validate_block_shape, validate_encode_shape, ErasureCoder, FecError};
|
||||
use super::{
|
||||
validate_block_shape, validate_encode_shape, validate_into_shape, ErasureCoder, FecError,
|
||||
};
|
||||
use crate::config::FecScheme;
|
||||
use fec_rs::ReedSolomon;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct Gf8Coder;
|
||||
#[derive(Default)]
|
||||
pub struct Gf8Coder {
|
||||
/// Last-used Cauchy codec, keyed by its `(k, m)` shape (plan Phase 1.4): video blocks
|
||||
/// keep one shape for long stretches (it only moves with frame size / adaptive-FEC
|
||||
/// steps), so caching the matrix kills the per-block generator construction. `Mutex`
|
||||
/// only to keep the `&self` trait surface; uncontended on the one send thread.
|
||||
rs: Mutex<Option<(usize, usize, ReedSolomon)>>,
|
||||
}
|
||||
|
||||
impl ErasureCoder for Gf8Coder {
|
||||
fn scheme(&self) -> FecScheme {
|
||||
@@ -16,20 +26,46 @@ impl ErasureCoder for Gf8Coder {
|
||||
}
|
||||
|
||||
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
||||
let mut out = Vec::new();
|
||||
self.encode_into(data, recovery_count, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_into(
|
||||
&self,
|
||||
data: &[&[u8]],
|
||||
recovery_count: usize,
|
||||
out: &mut Vec<Vec<u8>>,
|
||||
) -> Result<(), FecError> {
|
||||
if recovery_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
out.clear();
|
||||
return Ok(());
|
||||
}
|
||||
validate_encode_shape(data)?;
|
||||
let k = data.len();
|
||||
let shard_len = data[0].len();
|
||||
let rs = ReedSolomon::new(k, recovery_count)
|
||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let cached = matches!(&*guard, Some((ck, cm, _)) if *ck == k && *cm == recovery_count);
|
||||
if !cached {
|
||||
let rs = ReedSolomon::new(k, recovery_count)
|
||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||
*guard = Some((k, recovery_count, rs));
|
||||
}
|
||||
let rs = &guard.as_ref().expect("cache populated above").2;
|
||||
// Shape the caller's pooled parity buffers without zero-filling: `encode_sep`'s
|
||||
// first-input pass overwrites every parity row, so stale bytes never survive.
|
||||
out.truncate(recovery_count);
|
||||
for buf in out.iter_mut() {
|
||||
buf.resize(shard_len, 0);
|
||||
}
|
||||
while out.len() < recovery_count {
|
||||
out.push(vec![0u8; shard_len]);
|
||||
}
|
||||
// `encode_sep` reads the data shards by reference and fills the parity in place —
|
||||
// same Cauchy codec as `encode`, without copying the data into a shards scratch.
|
||||
let mut parity: Vec<Vec<u8>> = (0..recovery_count).map(|_| vec![0u8; shard_len]).collect();
|
||||
rs.encode_sep(data, &mut parity)
|
||||
rs.encode_sep(data, out)
|
||||
.map_err(|_| FecError::Backend("gf8 encode"))?;
|
||||
Ok(parity)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -56,6 +92,44 @@ impl ErasureCoder for Gf8Coder {
|
||||
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
||||
collect_originals(received, data_count)
|
||||
}
|
||||
|
||||
fn reconstruct_into(
|
||||
&self,
|
||||
recovery_count: usize,
|
||||
data: &mut [&mut [u8]],
|
||||
have: &[bool],
|
||||
recovery: &[(usize, &[u8])],
|
||||
) -> Result<(), FecError> {
|
||||
validate_into_shape(data, have, recovery, recovery_count)?;
|
||||
if have.iter().all(|h| *h) {
|
||||
return Ok(());
|
||||
}
|
||||
// Legacy-scheme shim: fec-rs reconstructs through owned `Option<Vec<u8>>` slots, so copy
|
||||
// the present shards into that shape and the recovered ones back out. Only P1/gf8
|
||||
// sessions on loss pay this — the hot gf16 path decodes straight into the caller's slots.
|
||||
let data_count = data.len();
|
||||
let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(data_count + recovery_count);
|
||||
for (s, h) in data.iter().zip(have) {
|
||||
received.push(h.then(|| s.to_vec()));
|
||||
}
|
||||
received.resize(data_count + recovery_count, None);
|
||||
for &(j, bytes) in recovery {
|
||||
received[data_count + j] = Some(bytes.to_vec());
|
||||
}
|
||||
let rs = ReedSolomon::new(data_count, recovery_count)
|
||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||
rs.reconstruct_data(&mut received)
|
||||
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
||||
for (i, h) in have.iter().enumerate() {
|
||||
if !*h {
|
||||
let shard = received[i]
|
||||
.as_ref()
|
||||
.ok_or(FecError::Backend("reconstruction left an original missing"))?;
|
||||
data[i].copy_from_slice(shard);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_originals(
|
||||
@@ -81,7 +155,7 @@ mod tests {
|
||||
/// these vectors would break and our parity would no longer be Moonlight-decodable.
|
||||
#[test]
|
||||
fn nanors_exact_parity_vectors() {
|
||||
let coder = Gf8Coder;
|
||||
let coder = Gf8Coder::default();
|
||||
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
|
||||
let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]];
|
||||
let parity = coder.encode(&data, 2).unwrap();
|
||||
@@ -103,7 +177,7 @@ mod tests {
|
||||
/// Round-trip: erase `m` data shards and confirm reconstruction recovers the originals.
|
||||
#[test]
|
||||
fn recovers_erased_data_shards() {
|
||||
let coder = Gf8Coder;
|
||||
let coder = Gf8Coder::default();
|
||||
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
|
||||
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||
let parity = coder.encode(&refs, 3).unwrap();
|
||||
|
||||
@@ -34,6 +34,23 @@ pub trait ErasureCoder: Send + Sync {
|
||||
/// buffer instead of copying every data byte into per-shard `Vec`s first.
|
||||
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
|
||||
|
||||
/// [`encode`](Self::encode) into caller-pooled parity buffers: on success `out` holds
|
||||
/// exactly `recovery_count` shards, reusing its existing `Vec` allocations (extras are
|
||||
/// truncated away, missing ones are grown once to the high-water mark). The per-frame
|
||||
/// hot path (plan Phase 1.4) — backends also reuse their internal codec state here, so
|
||||
/// steady-state frames cost no encoder construction and no parity allocations. The
|
||||
/// default delegates to `encode` (correct, unpooled) for backends without an override.
|
||||
/// On error `out`'s contents are unspecified and must not be sent.
|
||||
fn encode_into(
|
||||
&self,
|
||||
data: &[&[u8]],
|
||||
recovery_count: usize,
|
||||
out: &mut Vec<Vec<u8>>,
|
||||
) -> Result<(), FecError> {
|
||||
*out = self.encode(data, recovery_count)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
|
||||
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
|
||||
/// Returns the K original shards in order.
|
||||
@@ -43,13 +60,32 @@ pub trait ErasureCoder: Send + Sync {
|
||||
recovery_count: usize,
|
||||
received: &mut [Option<Vec<u8>>],
|
||||
) -> Result<Vec<Vec<u8>>, FecError>;
|
||||
|
||||
/// Reconstruct ONLY the missing data shards of a block, writing each straight into its final
|
||||
/// slot in the caller's buffer — the receive-side half of [`encode`](Self::encode)'s ref-based
|
||||
/// contract (the reassembler's slots are slices of one contiguous frame buffer, so recovery
|
||||
/// lands at its final AU offset with no per-shard `Vec`s and no block/AU concat copies).
|
||||
///
|
||||
/// `data` holds the block's K equal-length shard slots; `have[i]` marks the slots whose bytes
|
||||
/// were received (valid codec input — a missing slot's contents are unspecified on entry).
|
||||
/// `recovery` is the received parity as `(recovery_index, bytes)` with `recovery_index <
|
||||
/// recovery_count` (the block's declared M, which the codec math needs even when not all M
|
||||
/// arrived). On success every missing slot has been filled; on error missing slots are
|
||||
/// unspecified and the caller must discard the block.
|
||||
fn reconstruct_into(
|
||||
&self,
|
||||
recovery_count: usize,
|
||||
data: &mut [&mut [u8]],
|
||||
have: &[bool],
|
||||
recovery: &[(usize, &[u8])],
|
||||
) -> Result<(), FecError>;
|
||||
}
|
||||
|
||||
/// Construct the coder for a scheme.
|
||||
pub fn coder_for(scheme: FecScheme) -> Box<dyn ErasureCoder> {
|
||||
match scheme {
|
||||
FecScheme::Gf8 => Box::new(Gf8Coder),
|
||||
FecScheme::Gf16 => Box::new(Gf16Coder),
|
||||
FecScheme::Gf8 => Box::new(Gf8Coder::default()),
|
||||
FecScheme::Gf16 => Box::new(Gf16Coder::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +116,43 @@ pub(crate) fn validate_block_shape(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the shape [`ErasureCoder::reconstruct_into`] promises: `have` matches `data`, one
|
||||
/// shard length across data slots and recovery shards, recovery indices within the declared M,
|
||||
/// and enough shards present to reconstruct at all. Both backends call this first.
|
||||
pub(crate) fn validate_into_shape(
|
||||
data: &[&mut [u8]],
|
||||
have: &[bool],
|
||||
recovery: &[(usize, &[u8])],
|
||||
recovery_count: usize,
|
||||
) -> Result<(), FecError> {
|
||||
if data.is_empty() {
|
||||
return Err(FecError::Config("no data shards"));
|
||||
}
|
||||
if have.len() != data.len() {
|
||||
return Err(FecError::Config("have length must equal data length"));
|
||||
}
|
||||
let len = data[0].len();
|
||||
if data.iter().any(|s| s.len() != len) {
|
||||
return Err(FecError::Config("shards in a block must be equal length"));
|
||||
}
|
||||
for &(j, bytes) in recovery {
|
||||
if j >= recovery_count {
|
||||
return Err(FecError::Config("recovery index out of range"));
|
||||
}
|
||||
if bytes.len() != len {
|
||||
return Err(FecError::Config("shards in a block must be equal length"));
|
||||
}
|
||||
}
|
||||
let present = have.iter().filter(|h| **h).count();
|
||||
if present + recovery.len() < data.len() {
|
||||
return Err(FecError::TooFewShards {
|
||||
have: present + recovery.len(),
|
||||
need: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate `encode` inputs: at least one data shard, all of equal length.
|
||||
pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
|
||||
let first = data
|
||||
@@ -117,22 +190,109 @@ mod tests {
|
||||
assert_eq!(restored, data);
|
||||
}
|
||||
|
||||
/// Round-trip through `reconstruct_into`: encode, zero out `lose_data` slots in a contiguous
|
||||
/// buffer (the reassembler's frame-buffer shape), drop `lose_recovery` parity shards, and
|
||||
/// assert the missing slots are restored in place while the present ones are untouched.
|
||||
fn roundtrip_into(
|
||||
coder: &dyn ErasureCoder,
|
||||
k: usize,
|
||||
m: usize,
|
||||
shard_len: usize,
|
||||
lose_data: &[usize],
|
||||
lose_recovery: &[usize],
|
||||
) {
|
||||
let src: Vec<Vec<u8>> = (0..k)
|
||||
.map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect())
|
||||
.collect();
|
||||
let refs: Vec<&[u8]> = src.iter().map(|s| s.as_slice()).collect();
|
||||
let parity = coder.encode(&refs, m).unwrap();
|
||||
|
||||
let mut buf = vec![0u8; k * shard_len];
|
||||
let mut have = vec![true; k];
|
||||
for (i, s) in src.iter().enumerate() {
|
||||
if lose_data.contains(&i) {
|
||||
have[i] = false; // slot stays zeroed — codec must fill it
|
||||
} else {
|
||||
buf[i * shard_len..(i + 1) * shard_len].copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
let recovery: Vec<(usize, &[u8])> = parity
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| !lose_recovery.contains(j))
|
||||
.map(|(j, p)| (j, p.as_slice()))
|
||||
.collect();
|
||||
|
||||
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(shard_len).collect();
|
||||
coder
|
||||
.reconstruct_into(m, &mut slots, &have, &recovery)
|
||||
.unwrap();
|
||||
for (i, s) in src.iter().enumerate() {
|
||||
assert_eq!(
|
||||
&buf[i * shard_len..(i + 1) * shard_len],
|
||||
s.as_slice(),
|
||||
"shard {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gf16_reconstruct_into_fills_only_the_holes() {
|
||||
roundtrip_into(&Gf16Coder::default(), 16, 4, 256, &[1, 9], &[3]);
|
||||
roundtrip_into(&Gf16Coder::default(), 4, 2, 16, &[0, 3], &[]);
|
||||
roundtrip_into(&Gf16Coder::default(), 4, 2, 16, &[], &[0, 1]); // nothing missing, no parity needed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gf8_reconstruct_into_fills_only_the_holes() {
|
||||
roundtrip_into(&Gf8Coder::default(), 16, 4, 256, &[0, 7], &[1]);
|
||||
roundtrip_into(&Gf8Coder::default(), 4, 2, 16, &[2], &[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstruct_into_rejects_bad_shapes() {
|
||||
let mut buf = [0u8; 4 * 8];
|
||||
// Too few shards: 2 of 4 data present, no recovery.
|
||||
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
|
||||
let have = [true, true, false, false];
|
||||
assert!(Gf16Coder::default()
|
||||
.reconstruct_into(2, &mut slots, &have, &[])
|
||||
.is_err());
|
||||
// Recovery index out of the declared range.
|
||||
let parity = [0u8; 8];
|
||||
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
|
||||
assert!(Gf16Coder::default()
|
||||
.reconstruct_into(2, &mut slots, &have, &[(2, &parity), (3, &parity)])
|
||||
.is_err());
|
||||
// Mismatched recovery shard length.
|
||||
let short = [0u8; 6];
|
||||
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
|
||||
assert!(Gf8Coder::default()
|
||||
.reconstruct_into(2, &mut slots, &have, &[(0, &short), (1, &parity)])
|
||||
.is_err());
|
||||
// `have` length disagreeing with `data`.
|
||||
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
|
||||
assert!(Gf8Coder::default()
|
||||
.reconstruct_into(2, &mut slots, &[true; 3], &[(0, &parity)])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gf8_recovers_within_budget() {
|
||||
// 16 data + 4 recovery; lose 2 data + 2 recovery (== budget).
|
||||
roundtrip(&Gf8Coder, 16, 4, 256, &[0, 7, 16, 19]);
|
||||
roundtrip(&Gf8Coder::default(), 16, 4, 256, &[0, 7, 16, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gf16_recovers_within_budget() {
|
||||
roundtrip(&Gf16Coder, 16, 4, 256, &[1, 9, 17, 18]);
|
||||
roundtrip(&Gf16Coder::default(), 16, 4, 256, &[1, 9, 17, 18]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gf8_too_much_loss_errors() {
|
||||
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
|
||||
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||
let recovery = Gf8Coder.encode(&refs, 2).unwrap();
|
||||
let recovery = Gf8Coder::default().encode(&refs, 2).unwrap();
|
||||
let mut received: Vec<Option<Vec<u8>>> = data
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -143,8 +303,8 @@ mod tests {
|
||||
received[0] = None;
|
||||
received[1] = None;
|
||||
received[2] = None;
|
||||
assert!(Gf16Coder.scheme() == FecScheme::Gf16);
|
||||
let err = Gf8Coder.reconstruct(8, 2, &mut received);
|
||||
assert!(Gf16Coder::default().scheme() == FecScheme::Gf16);
|
||||
let err = Gf8Coder::default().reconstruct(8, 2, &mut received);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
@@ -153,9 +313,9 @@ mod tests {
|
||||
// data=2, recovery=2 expects a 4-element slice; a 3-element one must error, not
|
||||
// panic on the recovery-slice index (both backends).
|
||||
let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])];
|
||||
assert!(Gf16Coder.reconstruct(2, 2, &mut recv).is_err());
|
||||
assert!(Gf16Coder::default().reconstruct(2, 2, &mut recv).is_err());
|
||||
let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])];
|
||||
assert!(Gf8Coder.reconstruct(2, 2, &mut recv).is_err());
|
||||
assert!(Gf8Coder::default().reconstruct(2, 2, &mut recv).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -163,9 +323,9 @@ mod tests {
|
||||
// The GF16 fast path used to clone shards verbatim without a length check.
|
||||
let mut recv: Vec<Option<Vec<u8>>> =
|
||||
vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None];
|
||||
assert!(Gf16Coder.reconstruct(2, 2, &mut recv).is_err());
|
||||
assert!(Gf16Coder::default().reconstruct(2, 2, &mut recv).is_err());
|
||||
let mut recv: Vec<Option<Vec<u8>>> =
|
||||
vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None];
|
||||
assert!(Gf8Coder.reconstruct(2, 2, &mut recv).is_err());
|
||||
assert!(Gf8Coder::default().reconstruct(2, 2, &mut recv).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::error::{PunktfunkError, Result};
|
||||
use crate::fec::ErasureCoder;
|
||||
use crate::session::Frame;
|
||||
use crate::stats::StatsCounters;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
|
||||
|
||||
/// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
|
||||
@@ -147,6 +147,10 @@ pub struct Packetizer {
|
||||
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
|
||||
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
|
||||
tail: Vec<u8>,
|
||||
/// Reusable parity buffers for [`ErasureCoder::encode_into`] (plan Phase 1.4): grows once
|
||||
/// to the session's high-water recovery count, then every block's parity is generated
|
||||
/// into it with zero allocations.
|
||||
recovery: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Packetizer {
|
||||
@@ -159,6 +163,7 @@ impl Packetizer {
|
||||
fec: config.fec,
|
||||
version: config.phase as u8,
|
||||
tail: Vec::new(),
|
||||
recovery: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +267,7 @@ impl Packetizer {
|
||||
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
|
||||
}
|
||||
let tail = &self.tail;
|
||||
let recovery_pool = &mut self.recovery;
|
||||
let shard_at = |s: usize| -> &[u8] {
|
||||
if s < full_shards {
|
||||
&frame[s * payload..(s + 1) * payload]
|
||||
@@ -279,7 +285,8 @@ impl Packetizer {
|
||||
let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect();
|
||||
|
||||
let recovery_count = self.fec.recovery_for(block_data_count);
|
||||
let recovery = coder.encode(&data_shards, recovery_count)?;
|
||||
coder.encode_into(&data_shards, recovery_count, recovery_pool)?;
|
||||
let recovery = &*recovery_pool;
|
||||
let total_shards = block_data_count + recovery_count;
|
||||
if total_shards > u16::MAX as usize {
|
||||
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
|
||||
@@ -331,14 +338,28 @@ impl Packetizer {
|
||||
// Client side: reassembly + FEC recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct BlockBuf {
|
||||
/// Per-block reassembly state. The block's DATA bytes live in the owning [`FrameBuf::buf`]
|
||||
/// (each shard copied once, straight to its final AU offset); this tracks presence and holds
|
||||
/// the received recovery shards until the block resolves.
|
||||
struct BlockState {
|
||||
/// The block's K/M — pinned by the frame geometry derived from `frame_bytes` and validated
|
||||
/// against every packet of the block.
|
||||
data_shards: usize,
|
||||
recovery_shards: usize,
|
||||
shard_bytes: usize,
|
||||
/// Length `data_shards + recovery_shards`; `Some` = received.
|
||||
shards: Vec<Option<Vec<u8>>>,
|
||||
received: usize,
|
||||
/// Per-data-shard presence: which ranges of the frame buffer hold received bytes (also the
|
||||
/// FEC input map — the codec reads only present slots).
|
||||
have_data: Vec<bool>,
|
||||
data_received: usize,
|
||||
/// Received recovery shards (pooled shard-sized buffers, reclaimed when the block resolves).
|
||||
recovery: Vec<Option<Vec<u8>>>,
|
||||
recovery_received: usize,
|
||||
/// Terminal — either reconstructed (its buffer range is fully written) or unrecoverable
|
||||
/// (corrupt shards; the frame can never complete). Later shards for it are ignored.
|
||||
done: bool,
|
||||
/// The block resolved by actually consuming parity (`missing > 0` at reconstruct) — the only
|
||||
/// case where a data shard arriving after `done` was counted into `fec_recovered_shards` and
|
||||
/// must be netted back out as [`fec_late_shards`](crate::stats::Stats::fec_late_shards).
|
||||
reconstructed: bool,
|
||||
}
|
||||
|
||||
struct FrameBuf {
|
||||
@@ -346,9 +367,16 @@ struct FrameBuf {
|
||||
block_count: usize,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
blocks: HashMap<u16, BlockBuf>,
|
||||
/// Reconstructed payload per completed block, ordered by block index.
|
||||
block_data: BTreeMap<u16, Vec<u8>>,
|
||||
/// The whole frame's data region — `total_data_shards × shard_bytes` zeroed bytes. Data
|
||||
/// shards are copied to their final offset on arrival; FEC reconstruction writes only the
|
||||
/// missing shards' ranges. On completion this Vec IS [`Frame::data`] (truncated to
|
||||
/// `frame_bytes`) — the old shard→block→AU copy chain and its ~per-packet allocations are
|
||||
/// gone (the 2026-07-14 sweeps pinned the client pump as the ~1.5 Gbps wall, ~85% userspace).
|
||||
buf: Vec<u8>,
|
||||
blocks: HashMap<u16, BlockState>,
|
||||
/// Blocks fully reconstructed into `buf`. The frame completes when this reaches
|
||||
/// `block_count` (a failed block never counts — the frame then ages out as dropped).
|
||||
blocks_ok: usize,
|
||||
}
|
||||
|
||||
/// Per-session bounds the reassembler enforces on every packet header *before*
|
||||
@@ -392,15 +420,33 @@ impl ReassemblerLimits {
|
||||
#[derive(Default)]
|
||||
struct ReassemblyWindow {
|
||||
frames: HashMap<u32, FrameBuf>,
|
||||
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
||||
/// Recently-terminated frames (emitted OR abandoned by the loss window), so stray/late shards
|
||||
/// can't resurrect them. The value is the frame's parity-restored data shards (frame-wide
|
||||
/// index `block × max_data_shards + shard`, usually empty): each was counted into
|
||||
/// `fec_recovered_shards` at reconstruct, so when one ARRIVES after all — late, not lost —
|
||||
/// it's removed here and counted into `fec_late_shards` for the loss windows to net out
|
||||
/// (reordering alone must not read as packet loss). The removal makes the accounting exact:
|
||||
/// a wire duplicate of a shard that did arrive matches nothing and counts nothing. Pruned to
|
||||
/// the reorder window alongside `frames`.
|
||||
completed: HashSet<u32>,
|
||||
completed: HashMap<u32, Vec<u32>>,
|
||||
/// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
|
||||
/// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
|
||||
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
|
||||
newest_frame: Option<(u32, u64)>,
|
||||
}
|
||||
|
||||
/// Frame buffers are allocated whole (zeroed) at a frame's first shard, so bound how much a
|
||||
/// window of tiny first-shards can commit: the sum of in-flight `FrameBuf::buf` bytes (both index
|
||||
/// spaces) may not exceed `IN_FLIGHT_BUF_FACTOR × max_frame_bytes`. Honest streams hold 1–3
|
||||
/// partially-arrived frames of ACTUAL size (≪ max); without this cap, [`HARD_LOSS_WINDOW`]
|
||||
/// max-sized declarations from one header-sized packet each could commit gigabytes — an
|
||||
/// amplification the old sparse per-shard allocation didn't have.
|
||||
const IN_FLIGHT_BUF_FACTOR: usize = 4;
|
||||
|
||||
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
|
||||
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
|
||||
const RECOVERY_POOL_MAX: usize = 512;
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
/// Client-side only.
|
||||
pub struct Reassembler {
|
||||
@@ -414,6 +460,12 @@ pub struct Reassembler {
|
||||
/// video loss anchor). Aged-out probe frames are NOT `frames_dropped` — probe loss is measured
|
||||
/// bytes-wise by the probe accumulator and must not fire video recovery.
|
||||
probe: ReassemblyWindow,
|
||||
/// Reusable shard-sized buffers for received recovery shards — the only shard bytes that
|
||||
/// still need their own storage (data shards land straight in the frame buffer). Capped at
|
||||
/// [`RECOVERY_POOL_MAX`].
|
||||
recovery_pool: Vec<Vec<u8>>,
|
||||
/// Sum of in-flight `FrameBuf::buf` bytes across both windows (see [`IN_FLIGHT_BUF_FACTOR`]).
|
||||
in_flight_bytes: usize,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
@@ -422,6 +474,8 @@ impl Reassembler {
|
||||
limits,
|
||||
video: ReassemblyWindow::default(),
|
||||
probe: ReassemblyWindow::default(),
|
||||
recovery_pool: Vec::new(),
|
||||
in_flight_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +503,16 @@ impl Reassembler {
|
||||
}
|
||||
};
|
||||
|
||||
let lim = self.limits;
|
||||
// Disjoint field borrows: the window (`video`/`probe`), the recovery pool, and the
|
||||
// in-flight budget are all touched while a frame entry is mutably borrowed.
|
||||
let Reassembler {
|
||||
limits,
|
||||
video,
|
||||
probe,
|
||||
recovery_pool,
|
||||
in_flight_bytes,
|
||||
} = self;
|
||||
let lim = *limits;
|
||||
let shard_bytes = hdr.shard_bytes as usize;
|
||||
let data_shards = hdr.data_shards as usize;
|
||||
let recovery_shards = hdr.recovery_shards as usize;
|
||||
@@ -480,130 +543,219 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
||||
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame
|
||||
// into consecutive blocks of exactly `max_data_per_block` data shards with only the LAST
|
||||
// block smaller, and stamps the exact `frame_bytes` in every header. That makes every
|
||||
// data shard's final AU offset computable on arrival —
|
||||
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes
|
||||
// — which is what lets shards land straight in the frame buffer below. Enforce the
|
||||
// invariant so a header lying about its geometry is dropped instead of scribbling into
|
||||
// another shard's range.
|
||||
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
|
||||
let block_idx = hdr.block_index as usize;
|
||||
let expect_data_shards = if block_idx + 1 == expect_blocks {
|
||||
total_data - (expect_blocks - 1) * lim.max_data_shards
|
||||
} else {
|
||||
lim.max_data_shards
|
||||
};
|
||||
if block_count != expect_blocks || data_shards != expect_data_shards {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes];
|
||||
|
||||
// Route by index space: speed-test probe filler (FLAG_PROBE in user_flags) reassembles in
|
||||
// its own window so its indexes never interact with the video loss window — a probe burst
|
||||
// can neither advance the video anchor nor be dropped as stale against it (and its aged-out
|
||||
// frames never count as `frames_dropped`, which would fire video loss recovery).
|
||||
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
||||
let win = if is_probe {
|
||||
&mut self.probe
|
||||
} else {
|
||||
&mut self.video
|
||||
};
|
||||
win.advance_window(hdr.frame_index, hdr.pts_ns, stats, !is_probe);
|
||||
let win = if is_probe { probe } else { video };
|
||||
win.advance_window(
|
||||
hdr.frame_index,
|
||||
hdr.pts_ns,
|
||||
stats,
|
||||
!is_probe,
|
||||
recovery_pool,
|
||||
in_flight_bytes,
|
||||
lim.max_data_shards,
|
||||
);
|
||||
|
||||
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
|
||||
// frame that completed early via the all-originals-present fast path) or that
|
||||
// have fallen out of the loss window.
|
||||
if win.completed.contains(&hdr.frame_index) || win.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||
// Drop shards for frames already terminated (emitted — e.g. the recovery shards of a
|
||||
// frame that completed early via the all-originals-present fast path — or abandoned by
|
||||
// the loss window) and for frames that have fallen out of the loss window entirely.
|
||||
if let Some(reconstructed) = win.completed.get_mut(&hdr.frame_index) {
|
||||
// A data shard the parity reconstruct already restored (and counted into
|
||||
// `fec_recovered_shards`) was late, not lost: count the arrival so the loss windows
|
||||
// net it out (`recovered - late`), or plain reordering reads as packet loss and
|
||||
// spooks adaptive FEC + the bitrate controller. Removing the match keeps it exact —
|
||||
// wire duplicates of delivered shards match nothing, recovery shards are never in
|
||||
// the list. No probe/video split: `fec_recovered_shards` counts both windows.
|
||||
if shard_index < data_shards {
|
||||
let fw = block_idx as u32 * lim.max_data_shards as u32 + shard_index as u32;
|
||||
if let Some(pos) = reconstructed.iter().position(|&s| s == fw) {
|
||||
reconstructed.swap_remove(pos);
|
||||
StatsCounters::add(&stats.fec_late_shards, 1);
|
||||
}
|
||||
}
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
if win.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame establishes its geometry; later packets must agree.
|
||||
let frame = win
|
||||
.frames
|
||||
.entry(hdr.frame_index)
|
||||
.or_insert_with(|| FrameBuf {
|
||||
frame_bytes,
|
||||
block_count,
|
||||
pts_ns: hdr.pts_ns,
|
||||
user_flags: hdr.user_flags,
|
||||
blocks: HashMap::new(),
|
||||
block_data: BTreeMap::new(),
|
||||
});
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry.
|
||||
let buf_len = total_data * shard_bytes;
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
if *in_flight_bytes + buf_len > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
// Budget exhausted (several max-size frames all partially in flight) — a
|
||||
// stream this bites is already deep in loss; dropping the packet is strictly
|
||||
// milder than what the loss window would do to the frame moments later.
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += buf_len;
|
||||
e.insert(FrameBuf {
|
||||
frame_bytes,
|
||||
block_count,
|
||||
pts_ns: hdr.pts_ns,
|
||||
user_flags: hdr.user_flags,
|
||||
buf: vec![0; buf_len],
|
||||
blocks: HashMap::new(),
|
||||
blocks_ok: 0,
|
||||
})
|
||||
}
|
||||
};
|
||||
if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
let FrameBuf {
|
||||
buf,
|
||||
blocks,
|
||||
blocks_ok,
|
||||
..
|
||||
} = frame;
|
||||
|
||||
if frame.block_data.contains_key(&hdr.block_index) {
|
||||
return Ok(None); // block already reconstructed; late/duplicate shard
|
||||
}
|
||||
|
||||
// First packet of a block sizes its shard vector; later packets must match its
|
||||
// (data, recovery, shard_bytes) geometry, so `shard_index` is always in bounds.
|
||||
frame
|
||||
.blocks
|
||||
.entry(hdr.block_index)
|
||||
.or_insert_with(|| BlockBuf {
|
||||
data_shards,
|
||||
recovery_shards,
|
||||
shard_bytes,
|
||||
shards: vec![None; total],
|
||||
received: 0,
|
||||
done: false,
|
||||
});
|
||||
let block = frame.blocks.get_mut(&hdr.block_index).unwrap();
|
||||
if block.data_shards != data_shards
|
||||
|| block.recovery_shards != recovery_shards
|
||||
|| block.shard_bytes != shard_bytes
|
||||
{
|
||||
// First packet of a block sizes its state; `data_shards` is already pinned by the
|
||||
// derived geometry above, but `recovery_shards` is per-block wire input (adaptive FEC
|
||||
// varies it per frame) — later packets must match the block's first.
|
||||
let block = blocks.entry(hdr.block_index).or_insert_with(|| BlockState {
|
||||
data_shards,
|
||||
recovery_shards,
|
||||
have_data: vec![false; data_shards],
|
||||
data_received: 0,
|
||||
recovery: vec![None; recovery_shards],
|
||||
recovery_received: 0,
|
||||
done: false,
|
||||
reconstructed: false,
|
||||
});
|
||||
if block.recovery_shards != recovery_shards {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
if block.done {
|
||||
// A data shard the parity reconstruct already restored (`!have_data`) was late, not
|
||||
// lost — net it out of the `fec_recovered_shards` it was counted into (see the
|
||||
// completed-frame twin above; this arm covers multi-block frames whose other blocks
|
||||
// are still in flight). `have_data == true` = wire duplicate; a failed reconstruct
|
||||
// (`!reconstructed`) never counted its missing shards, so neither do we.
|
||||
if block.reconstructed
|
||||
&& shard_index < block.data_shards
|
||||
&& !block.have_data[shard_index]
|
||||
{
|
||||
block.have_data[shard_index] = true; // it HAS arrived now — dedups a re-dup
|
||||
StatsCounters::add(&stats.fec_late_shards, 1);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if block.shards[shard_index].is_none() {
|
||||
block.shards[shard_index] = Some(payload);
|
||||
block.received += 1;
|
||||
if shard_index < data_shards {
|
||||
// A data shard lands at its final AU offset — the only copy its bytes ever make
|
||||
// past decrypt.
|
||||
if !block.have_data[shard_index] {
|
||||
let off = (block_idx * lim.max_data_shards + shard_index) * shard_bytes;
|
||||
buf[off..off + shard_bytes].copy_from_slice(body);
|
||||
block.have_data[shard_index] = true;
|
||||
block.data_received += 1;
|
||||
}
|
||||
} else {
|
||||
let slot = shard_index - data_shards;
|
||||
if block.recovery[slot].is_none() {
|
||||
let mut rb = recovery_pool.pop().unwrap_or_default();
|
||||
rb.clear();
|
||||
rb.extend_from_slice(body);
|
||||
block.recovery[slot] = Some(rb);
|
||||
block.recovery_received += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct as soon as we hold enough shards.
|
||||
if !block.done && block.received >= block.data_shards {
|
||||
let present_data = block.shards[..block.data_shards]
|
||||
.iter()
|
||||
.filter(|s| s.is_some())
|
||||
.count();
|
||||
let recovered = match coder.reconstruct(
|
||||
block.data_shards,
|
||||
block.recovery_shards,
|
||||
&mut block.shards,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
if block.data_received + block.recovery_received >= block.data_shards {
|
||||
let missing = block.data_shards - block.data_received;
|
||||
let outcome = if missing == 0 {
|
||||
Ok(()) // every original arrived — its bytes are already in place
|
||||
} else {
|
||||
let base = block_idx * lim.max_data_shards * shard_bytes;
|
||||
let region = &mut buf[base..base + block.data_shards * shard_bytes];
|
||||
let mut slots: Vec<&mut [u8]> = region.chunks_mut(shard_bytes).collect();
|
||||
let parity: Vec<(usize, &[u8])> = block
|
||||
.recovery
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, s)| s.as_deref().map(|b| (j, b)))
|
||||
.collect();
|
||||
coder.reconstruct_into(block.recovery_shards, &mut slots, &block.have_data, &parity)
|
||||
};
|
||||
// The parity buffers are spent either way — reclaim them for the next block.
|
||||
for slot in block.recovery.iter_mut() {
|
||||
if let Some(rb) = slot.take() {
|
||||
if recovery_pool.len() < RECOVERY_POOL_MAX {
|
||||
recovery_pool.push(rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
block.done = true;
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
// With in-order delivery `missing` is exactly the block's lost shards; under
|
||||
// reordering the early trigger also "recovers" shards that are merely still
|
||||
// in flight — their later arrival counts `fec_late_shards` (both arms above)
|
||||
// so loss estimators can net the two (`window_loss_ppm`).
|
||||
block.reconstructed = missing > 0;
|
||||
StatsCounters::add(&stats.fec_recovered_shards, missing as u64);
|
||||
*blocks_ok += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
// Corrupt/incompatible shards that slipped past the header checks: discard this
|
||||
// block (mark done so later shards for it are ignored) and keep the session
|
||||
// alive — a lossy link must not be torn down by one unrecoverable block; the
|
||||
// frame stays incomplete and the client recovers at the next keyframe/RFI.
|
||||
block.done = true;
|
||||
// Corrupt/incompatible shards that slipped past the header checks: discard
|
||||
// this block (done, but never counted ok — the frame can't complete and ages
|
||||
// out) and keep the session alive; the client recovers at the next
|
||||
// keyframe/RFI.
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
block.done = true;
|
||||
StatsCounters::add(
|
||||
&stats.fec_recovered_shards,
|
||||
(block.data_shards - present_data) as u64,
|
||||
);
|
||||
|
||||
// Concatenate the block's data shards into its contiguous payload.
|
||||
let mut block_payload = Vec::with_capacity(block.data_shards * block.shard_bytes);
|
||||
for shard in &recovered {
|
||||
block_payload.extend_from_slice(shard);
|
||||
}
|
||||
frame.block_data.insert(hdr.block_index, block_payload);
|
||||
frame.blocks.remove(&hdr.block_index);
|
||||
}
|
||||
|
||||
// Whole frame ready?
|
||||
if frame.block_data.len() == frame.block_count {
|
||||
let frame = win.frames.remove(&hdr.frame_index).unwrap();
|
||||
win.completed.insert(hdr.frame_index);
|
||||
// Reserve based on the bytes we actually hold, not the (already-bounded but
|
||||
// still caller-supplied) frame_bytes, so a small frame can't over-reserve.
|
||||
let actual: usize = frame.block_data.values().map(|b| b.len()).sum();
|
||||
let mut data = Vec::with_capacity(actual);
|
||||
for (_, block_payload) in frame.block_data.into_iter() {
|
||||
data.extend_from_slice(&block_payload);
|
||||
}
|
||||
data.truncate(frame.frame_bytes); // trim trailing-shard zero padding
|
||||
if *blocks_ok == block_count {
|
||||
let mut done = win.frames.remove(&hdr.frame_index).unwrap();
|
||||
win.completed.insert(
|
||||
hdr.frame_index,
|
||||
reconstructed_shards(&done.blocks, lim.max_data_shards),
|
||||
);
|
||||
*in_flight_bytes -= done.buf.len();
|
||||
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
|
||||
return Ok(Some(Frame {
|
||||
data,
|
||||
data: done.buf,
|
||||
frame_index: hdr.frame_index,
|
||||
pts_ns: frame.pts_ns,
|
||||
flags: frame.user_flags,
|
||||
pts_ns: done.pts_ns,
|
||||
flags: done.user_flags,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
@@ -618,20 +770,45 @@ impl Reassembler {
|
||||
pub fn reset(&mut self) {
|
||||
self.video = ReassemblyWindow::default();
|
||||
self.probe = ReassemblyWindow::default();
|
||||
// The dropped frames' buffers (and their parity bufs) go back to the allocator, not the
|
||||
// pool — a flush is the rare path. The budget resets with them.
|
||||
self.in_flight_bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The data shards of a terminating frame that only exist because parity restored them
|
||||
/// (`reconstructed` blocks' still-absent originals), as frame-wide indexes
|
||||
/// (`block × max_data_shards + shard`) for the [`ReassemblyWindow::completed`] late-shard
|
||||
/// memory. Empty (no allocation) for the overwhelmingly common clean frame.
|
||||
fn reconstructed_shards(blocks: &HashMap<u16, BlockState>, max_data_shards: usize) -> Vec<u32> {
|
||||
let mut v = Vec::new();
|
||||
for (&bi, b) in blocks {
|
||||
if b.reconstructed {
|
||||
for (i, have) in b.have_data.iter().enumerate() {
|
||||
if !have {
|
||||
v.push(bi as u32 * max_data_shards as u32 + i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
impl ReassemblyWindow {
|
||||
/// Track the newest frame, declare incomplete frames that fell out of the loss window
|
||||
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — for the
|
||||
/// video window (`count_drops`) counting them dropped, which is what drives the client's
|
||||
/// recovery-keyframe request — and prune the completed-index memory to [`REORDER_WINDOW`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn advance_window(
|
||||
&mut self,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
stats: &StatsCounters,
|
||||
count_drops: bool,
|
||||
recovery_pool: &mut Vec<Vec<u8>>,
|
||||
in_flight_bytes: &mut usize,
|
||||
max_data_shards: usize,
|
||||
) {
|
||||
let (newest, newest_pts) = match self.newest_frame {
|
||||
// `frame_index` is newer iff it's within the forward half of the index space.
|
||||
@@ -648,8 +825,21 @@ impl ReassemblyWindow {
|
||||
if !keep {
|
||||
// Remember the abandoned index so a straggler shard is dropped (below, and in
|
||||
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
|
||||
// and double-count the drop when it aged out again.
|
||||
completed.insert(idx);
|
||||
// and double-count the drop when it aged out again. Blocks that reconstructed
|
||||
// before the frame died still counted `fec_recovered_shards`, so their restored
|
||||
// shards join the late-shard memory exactly like an emitted frame's.
|
||||
completed.insert(idx, reconstructed_shards(&f.blocks, max_data_shards));
|
||||
// Release its buffer budget and reclaim its parity bufs for the pool.
|
||||
*in_flight_bytes -= f.buf.len();
|
||||
for block in f.blocks.values_mut() {
|
||||
for slot in block.recovery.iter_mut() {
|
||||
if let Some(rb) = slot.take() {
|
||||
if recovery_pool.len() < RECOVERY_POOL_MAX {
|
||||
recovery_pool.push(rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keep
|
||||
});
|
||||
@@ -658,7 +848,7 @@ impl ReassemblyWindow {
|
||||
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
||||
}
|
||||
self.completed
|
||||
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
||||
.retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
||||
}
|
||||
|
||||
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
|
||||
@@ -957,6 +1147,205 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a host config for the end-to-end roundtrips: 16-byte shards, 4-data-shard blocks.
|
||||
fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config {
|
||||
use crate::config::{FecConfig, ProtocolPhase, Role};
|
||||
Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme,
|
||||
fec_percent,
|
||||
max_data_per_block: 4,
|
||||
},
|
||||
shard_payload: 16,
|
||||
max_frame_bytes: 4096,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize a synthetic AU, deliver a mangled subset (losses within the FEC budget,
|
||||
/// optionally reversed, with a duplicate), and assert the reassembled AU is byte-identical
|
||||
/// to the source — the shards landed straight in the frame buffer at the right offsets and
|
||||
/// FEC filled the holes.
|
||||
///
|
||||
/// `fec_recovered_shards` accounting: with in-order delivery it equals the kill count
|
||||
/// exactly (and nothing is late). With reversed delivery parity arrives first, so the
|
||||
/// `data + recovery ≥ k` trigger reconstructs EARLY and restores late-not-lost shards too —
|
||||
/// deliberate (latency), but each such shard's later arrival must count `fec_late_shards`
|
||||
/// so the NET (`recovered - late`) still equals the true kill count: reordering alone must
|
||||
/// not read as loss (it pollutes LossReports → adaptive FEC + the ABR controller).
|
||||
fn e2e_roundtrip(
|
||||
scheme: FecScheme,
|
||||
frame_len: usize,
|
||||
fec_percent: u8,
|
||||
kill: &[usize],
|
||||
reverse: bool,
|
||||
) {
|
||||
let cfg = e2e_config(scheme, fec_percent);
|
||||
let coder = coder_for(scheme);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let src: Vec<u8> = (0..frame_len).map(|i| (i * 131 + 7) as u8).collect();
|
||||
let pkts = pk.packetize(&src, 12345, 0, coder.as_ref()).unwrap();
|
||||
|
||||
let mut delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !kill.contains(i))
|
||||
.map(|(_, p)| p.clone())
|
||||
.collect();
|
||||
if reverse {
|
||||
delivery.reverse(); // recovery shards (and the tail) arrive first
|
||||
}
|
||||
if let Some(dup) = delivery.first().cloned() {
|
||||
delivery.push(dup); // a duplicate must be ignored, not double-counted
|
||||
}
|
||||
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let mut got = None;
|
||||
for p in &delivery {
|
||||
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
|
||||
assert!(got.is_none(), "frame must complete exactly once");
|
||||
got = Some(f);
|
||||
}
|
||||
}
|
||||
let f = got.expect("frame must complete within the FEC budget");
|
||||
assert_eq!(f.data, src, "reassembled AU must be byte-identical");
|
||||
assert_eq!(f.pts_ns, 12345);
|
||||
let snap = stats.snapshot();
|
||||
let (recovered, late) = (snap.fec_recovered_shards, snap.fec_late_shards);
|
||||
if reverse {
|
||||
assert!(
|
||||
recovered >= kill.len() as u64,
|
||||
"early reconstruct counts more"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(recovered, kill.len() as u64);
|
||||
}
|
||||
assert_eq!(
|
||||
recovered - late,
|
||||
kill.len() as u64,
|
||||
"net recovered (recovered - late) must equal the true loss regardless of order \
|
||||
(recovered={recovered} late={late} killed={})",
|
||||
kill.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-block frame with a partial tail shard, heavy loss, both delivery orders + dups.
|
||||
/// 100 bytes / 16 = 7 shards → blocks of (4 data + 2 rec) and (3 data + 2 rec).
|
||||
#[test]
|
||||
fn e2e_multiblock_loss_reorder_dup_gf16() {
|
||||
// Packet order: blk0 = idx 0..6 (4 data + 2 rec), blk1 = idx 6..11 (3 data + 2 rec).
|
||||
// Kill 2 data in block 0 and 1 data in block 1 — all within the 50% budget.
|
||||
e2e_roundtrip(FecScheme::Gf16, 100, 50, &[0, 2, 7], false);
|
||||
e2e_roundtrip(FecScheme::Gf16, 100, 50, &[0, 2, 7], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_multiblock_loss_reorder_dup_gf8() {
|
||||
e2e_roundtrip(FecScheme::Gf8, 100, 50, &[1, 3, 8], false);
|
||||
e2e_roundtrip(FecScheme::Gf8, 100, 50, &[1, 3, 8], true);
|
||||
}
|
||||
|
||||
/// Zero losses, in order: the pure fast path (no codec call, recovered == 0) must still
|
||||
/// emit an identical AU.
|
||||
#[test]
|
||||
fn e2e_clean_delivery_gf16() {
|
||||
e2e_roundtrip(FecScheme::Gf16, 100, 50, &[], false);
|
||||
}
|
||||
|
||||
/// An empty AU rides one zero-padded shard and reassembles to zero bytes.
|
||||
#[test]
|
||||
fn e2e_empty_frame() {
|
||||
let cfg = e2e_config(FecScheme::Gf16, 0);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let pkts = pk.packetize(&[], 7, 0, coder.as_ref()).unwrap();
|
||||
assert_eq!(pkts.len(), 1);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = r
|
||||
.push(&pkts[0], coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.expect("empty frame completes");
|
||||
assert!(f.data.is_empty());
|
||||
}
|
||||
|
||||
/// Loss beyond the FEC budget: the frame never emits, ages out as dropped, and the
|
||||
/// unrecoverable-block path must not fire (block never gathers k shards at all).
|
||||
#[test]
|
||||
fn e2e_unrecoverable_loss_ages_out() {
|
||||
let cfg = e2e_config(FecScheme::Gf16, 50);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let src = vec![0x5Au8; 64]; // one block: 4 data + 2 recovery
|
||||
let pkts = pk.packetize(&src, 1_000, 0, coder.as_ref()).unwrap();
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
// Deliver only 3 of 6 shards (k=4): can never reconstruct.
|
||||
for p in &pkts[..3] {
|
||||
assert!(r.push(p, coder.as_ref(), &stats).unwrap().is_none());
|
||||
}
|
||||
// A newer frame past the loss window ages it out as a video drop.
|
||||
let next = pk
|
||||
.packetize(&src, 1_000 + LOSS_WINDOW_NS + 1, 0, coder.as_ref())
|
||||
.unwrap();
|
||||
let mut done = false;
|
||||
for p in &next {
|
||||
done |= r.push(p, coder.as_ref(), &stats).unwrap().is_some();
|
||||
}
|
||||
assert!(done);
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||
}
|
||||
|
||||
/// The in-flight buffer budget: a window of tiny first-shards all declaring max-size frames
|
||||
/// stops allocating at [`IN_FLIGHT_BUF_FACTOR`] × max_frame_bytes instead of committing
|
||||
/// gigabytes (the eager whole-frame buffer's amplification defense).
|
||||
#[test]
|
||||
fn in_flight_buffer_budget_bounds_allocation() {
|
||||
let lim = limits(); // max_frame_bytes 4096, shards 16 B, ≤8 data shards × ≤4 blocks
|
||||
let mut r = Reassembler::new(lim);
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
// Largest geometry-consistent frame: 4 blocks × 8 shards × 16 B = 512 B per buffer.
|
||||
// Budget = 4 × 4096 = 16384 B → exactly 32 such frames fit; the 33rd must be refused.
|
||||
for i in 0..33u32 {
|
||||
let mut h = base_header();
|
||||
h.frame_index = i;
|
||||
h.frame_bytes = 512;
|
||||
h.block_count = 4;
|
||||
h.data_shards = 8;
|
||||
r.push(&packet(h), coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the frame past the budget is dropped, everything under it accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A header whose (data_shards, block_count) disagree with the geometry derived from its own
|
||||
/// frame_bytes is dropped — the derived-offset invariant that lets shards land directly in
|
||||
/// the frame buffer.
|
||||
#[test]
|
||||
fn rejects_geometry_inconsistent_with_frame_bytes() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
let mut h = base_header();
|
||||
h.frame_bytes = 16; // exactly one shard…
|
||||
h.data_shards = 2; // …but claims two
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(stats.snapshot().packets_dropped, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
|
||||
@@ -415,9 +415,10 @@ pub struct SetBitrate {
|
||||
}
|
||||
|
||||
/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the
|
||||
/// request clamped to its supported band). The encoder switches on the next frame (an IDR); the
|
||||
/// stream never pauses. Also the controller's liveness signal: no answer ⇒ an old host that
|
||||
/// doesn't renegotiate bitrate.
|
||||
/// request clamped to its supported band). The encoder retargets in place where the backend can
|
||||
/// (no IDR — the stream carries straight on); a backend without in-place reconfigure rebuilds and
|
||||
/// switches on the next frame (an IDR). The stream never pauses either way. Also the controller's
|
||||
/// liveness signal: no answer ⇒ an old host that doesn't renegotiate bitrate.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BitrateChanged {
|
||||
pub bitrate_kbps: u32,
|
||||
@@ -1147,13 +1148,19 @@ impl BitrateChanged {
|
||||
}
|
||||
|
||||
/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered
|
||||
/// (the loss it absorbed), shards received, and frames that went unrecoverable. Loss ≈ recovered /
|
||||
/// (received + recovered) — the fraction of shards that arrived missing. A frame drop means loss
|
||||
/// exceeded the current FEC budget (so `recovered` plateaus), so add a fixed bump to push the host's
|
||||
/// FEC up past the cap on the next adjustment. Returns parts-per-million, capped at 1e6.
|
||||
pub fn window_loss_ppm(recovered: u64, received: u64, frames_dropped: u64) -> u32 {
|
||||
let denom = received.saturating_add(recovered);
|
||||
let mut ppm = recovered
|
||||
/// (the loss it absorbed), recovered-but-then-arrived shards (`late` — reordered delivery lets a
|
||||
/// block reconstruct early, so those were never lost; netting them out keeps plain reordering from
|
||||
/// reading as packet loss and spooking adaptive FEC + the bitrate controller), shards received,
|
||||
/// and frames that went unrecoverable. Loss ≈ (recovered − late) / (received + recovered − late) —
|
||||
/// the fraction of shards that truly never arrived (a late shard is inside `received`, so the
|
||||
/// denominator nets it too; saturating, so reorder straddling a window boundary can't go
|
||||
/// negative). A frame drop means loss exceeded the current FEC budget (so `recovered` plateaus),
|
||||
/// so add a fixed bump to push the host's FEC up past the cap on the next adjustment. Returns
|
||||
/// parts-per-million, capped at 1e6.
|
||||
pub fn window_loss_ppm(recovered: u64, late: u64, received: u64, frames_dropped: u64) -> u32 {
|
||||
let lost = recovered.saturating_sub(late);
|
||||
let denom = received.saturating_add(lost);
|
||||
let mut ppm = lost
|
||||
.saturating_mul(1_000_000)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
@@ -275,18 +275,45 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::DualSense,
|
||||
GamepadPref::XboxOne,
|
||||
GamepadPref::DualShock4,
|
||||
GamepadPref::SteamController,
|
||||
GamepadPref::SteamDeck,
|
||||
GamepadPref::DualSenseEdge,
|
||||
GamepadPref::SwitchPro,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
||||
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
||||
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
||||
// Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers
|
||||
// that only know a prefix of the range).
|
||||
for (v, p) in [
|
||||
(0, GamepadPref::Auto),
|
||||
(1, GamepadPref::Xbox360),
|
||||
(2, GamepadPref::DualSense),
|
||||
(3, GamepadPref::XboxOne),
|
||||
(4, GamepadPref::DualShock4),
|
||||
(5, GamepadPref::SteamController),
|
||||
(6, GamepadPref::SteamDeck),
|
||||
(7, GamepadPref::DualSenseEdge),
|
||||
(8, GamepadPref::SwitchPro),
|
||||
] {
|
||||
assert_eq!(p.to_u8(), v);
|
||||
assert_eq!(GamepadPref::from_u8(v), p);
|
||||
}
|
||||
// The next unassigned byte degrades to Auto today; assigning it later must update this.
|
||||
assert_eq!(GamepadPref::from_u8(9), GamepadPref::Auto);
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("edge"),
|
||||
Some(GamepadPref::DualSenseEdge)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("Switch-Pro"),
|
||||
Some(GamepadPref::SwitchPro)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
@@ -680,15 +707,22 @@ fn loss_report_roundtrip() {
|
||||
#[test]
|
||||
fn window_loss_ppm_estimates_and_caps() {
|
||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||
assert_eq!(window_loss_ppm(0, 0, 0), 0);
|
||||
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
|
||||
assert_eq!(window_loss_ppm(0, 0, 0, 0), 0);
|
||||
assert_eq!(window_loss_ppm(0, 0, 1000, 0), 0);
|
||||
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
|
||||
assert_eq!(window_loss_ppm(50, 0, 950, 0), 50_000);
|
||||
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
|
||||
assert_eq!(window_loss_ppm(50, 0, 950, 1), 100_000);
|
||||
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
|
||||
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
|
||||
assert_eq!(window_loss_ppm(0, 0, 0, 3), 50_000);
|
||||
assert!(window_loss_ppm(u64::MAX, 0, 1, 9) <= 1_000_000);
|
||||
// Reordering: shards "recovered" early that then arrived are late, not lost — netted out, so
|
||||
// a pure-reorder window reads 0. Partially late nets to the true loss (20 of 1000 = 2%).
|
||||
assert_eq!(window_loss_ppm(50, 50, 1000, 0), 0);
|
||||
assert_eq!(window_loss_ppm(50, 30, 980, 0), 20_000);
|
||||
// `late` can outrun `recovered` across a window boundary (reorder straddling the report
|
||||
// tick) or via a rare wire duplicate — saturate at a clean window, never underflow.
|
||||
assert_eq!(window_loss_ppm(10, 25, 1000, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,7 +37,8 @@ pub struct Frame {
|
||||
pub struct Session {
|
||||
config: Config,
|
||||
coder: Box<dyn ErasureCoder>,
|
||||
crypto: Option<SessionCrypto>,
|
||||
/// `Arc` so the second seal lane (Phase 1.5) can share the cipher; uncontended otherwise.
|
||||
crypto: Option<std::sync::Arc<SessionCrypto>>,
|
||||
/// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when
|
||||
/// `crypto` is — the plaintext probe path carries no sequence to filter on.
|
||||
replay: Option<ReplayWindow>,
|
||||
@@ -59,19 +60,190 @@ pub struct Session {
|
||||
/// then returns them via [`reclaim_wires`](Self::reclaim_wires)). After warmup each buffer keeps
|
||||
/// its capacity, so the per-packet ciphertext + wire `Vec` allocations vanish from the hot path.
|
||||
wire_pool: Vec<Vec<u8>>,
|
||||
/// Receive-path stage timing (`PUNKTFUNK_PERF`), read+reset via [`take_pump_perf`]
|
||||
/// (Self::take_pump_perf). `None` when disabled — the hot path then pays one branch per stage.
|
||||
perf: Option<PumpPerf>,
|
||||
/// Send-path stage timing (`PUNKTFUNK_PERF`), read+reset via [`take_seal_perf`]
|
||||
/// (Self::take_seal_perf). Same arming + branch-cost contract as `perf`.
|
||||
seal_perf: Option<SealPerf>,
|
||||
/// The second seal lane (plan Phase 1.5), lazily spawned by the first frame that crosses
|
||||
/// [`TWO_LANE_MIN_PACKETS`]. Host sessions only (client sessions never seal frames).
|
||||
seal_lane: Option<SealLane>,
|
||||
/// Two-lane sealing enabled (default). `PUNKTFUNK_SEAL_LANES=1` forces single-lane.
|
||||
seal_two_lane: bool,
|
||||
/// Reused header-Vec for the lane hand-off (the worker's half round-trips through this,
|
||||
/// so steady-state two-lane frames move `n/2` Vec headers with zero allocation).
|
||||
lane_scratch: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
|
||||
/// pkt/s this is ~4k syscalls/s instead of 125k; the buffers cost `RECV_BATCH × RECV_BUF` (~64 KB).
|
||||
const RECV_BATCH: usize = 32;
|
||||
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
|
||||
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
|
||||
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
|
||||
/// at 60 fps — small frames and the probe's ~17-packet AUs stay strictly single-lane.
|
||||
const TWO_LANE_MIN_PACKETS: usize = 256;
|
||||
|
||||
/// One two-lane seal hand-off: the frame's back-half wire buffers, sealed by the worker with
|
||||
/// nonces `seq_base + i` (the nonce order is deterministic per shard index, which is what
|
||||
/// makes the split sound). Round-trips through the channels so the buffers return to the pool.
|
||||
struct SealJob {
|
||||
bufs: Vec<Vec<u8>>,
|
||||
seq_base: u64,
|
||||
timed: bool,
|
||||
/// Worker-lane CPU ns (when `timed`) and the seal outcome, filled in by the worker.
|
||||
ns: u64,
|
||||
result: Result<()>,
|
||||
}
|
||||
|
||||
/// The persistent second seal lane: a worker thread that AES-GCM-seals the back half of a
|
||||
/// large frame's packets while the send thread seals the front half. Rendezvous channels
|
||||
/// (bound 1) — the send thread submits, seals its half, then waits; no per-frame spawn.
|
||||
/// Dropping the struct closes the channel and the worker exits.
|
||||
struct SealLane {
|
||||
to_worker: std::sync::mpsc::SyncSender<SealJob>,
|
||||
from_worker: std::sync::mpsc::Receiver<SealJob>,
|
||||
}
|
||||
|
||||
impl SealLane {
|
||||
fn spawn(crypto: std::sync::Arc<SessionCrypto>) -> Option<SealLane> {
|
||||
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-seal2".into())
|
||||
.spawn(move || {
|
||||
while let Ok(mut job) = jobs.recv() {
|
||||
let t0 = job.timed.then(std::time::Instant::now);
|
||||
job.result = seal_wire_slice(&crypto, &mut job.bufs, job.seq_base);
|
||||
if let Some(t0) = t0 {
|
||||
job.ns = t0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
if done_tx.send(job).is_err() {
|
||||
break; // session gone mid-frame — nothing left to seal for
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
Some(SealLane {
|
||||
to_worker,
|
||||
from_worker,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Seal a run of pre-written wire buffers in place: buffer `i` is `seq(8) ‖ plaintext ‖ tag
|
||||
/// scratch` and seals over `[8..]` with sequence `seq_base + i` — the exact per-packet layout
|
||||
/// and nonce order of the fused single-lane path. Shared by both lanes.
|
||||
fn seal_wire_slice(c: &SessionCrypto, wires: &mut [Vec<u8>], seq_base: u64) -> Result<()> {
|
||||
for (i, wire) in wires.iter_mut().enumerate() {
|
||||
c.seal_in_place(seq_base.wrapping_add(i as u64), &mut wire[8..])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`].
|
||||
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
|
||||
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
|
||||
/// shard copies and block reconstruction). 2026-07-14 sweep context: the pump pegs one core at
|
||||
/// ~1.5 Gbps wire, ~85% of it userspace — this split is what Phase 2.1 (pooled reassembly) is
|
||||
/// validated against.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct PumpPerf {
|
||||
/// ns inside `recv_batch` (recvmmsg / recvmsg_x), i.e. syscall + kernel copy.
|
||||
pub recv_ns: u64,
|
||||
/// ns inside `open_in_place` across all datagrams (AES-128-GCM + replay-window upkeep).
|
||||
pub decrypt_ns: u64,
|
||||
/// ns inside `Reassembler::push` (header parse, shard copy, FEC reconstruct, AU assembly).
|
||||
pub reasm_ns: u64,
|
||||
/// recv_batch calls (batches) and datagrams processed over the accumulation window.
|
||||
pub batches: u64,
|
||||
pub packets: u64,
|
||||
}
|
||||
|
||||
/// Accumulated host send-path stage timings since the last [`Session::take_seal_perf`] (plan
|
||||
/// Phase 0.4, host half). Answers "where does the send thread go" at rate: FEC parity
|
||||
/// generation (`fec_ns`, inside [`ErasureCoder::encode_into`]) vs AES-GCM (`seal_ns`,
|
||||
/// per-packet `seal_in_place`) vs the socket handoff (`sock_ns` — `send_gso`/`sendmmsg`
|
||||
/// syscalls; the internal submit paths time it here, the paced video path folds its chunk
|
||||
/// sends in via [`Session::note_sock_ns`]). The Phase 1.5 gate reads off this split: build
|
||||
/// two-lane seal only if `seal_ns` exceeds ~15% of the send thread at 2 Gbps.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct SealPerf {
|
||||
/// ns inside `ErasureCoder::encode_into` (parity generation).
|
||||
pub fec_ns: u64,
|
||||
/// ns inside `seal_in_place` across all wire packets (AES-128-GCM).
|
||||
pub seal_ns: u64,
|
||||
/// ns inside `send_sealed` (socket syscalls), where the session can see it.
|
||||
pub sock_ns: u64,
|
||||
/// Frames sealed and wire packets sealed over the accumulation window.
|
||||
pub frames: u64,
|
||||
pub packets: u64,
|
||||
}
|
||||
|
||||
/// [`ErasureCoder`] shim accumulating the time spent in `encode_into` (the send-path FEC
|
||||
/// stage) — only constructed when `PUNKTFUNK_PERF` armed the session's [`SealPerf`]. The
|
||||
/// counter is atomic purely to satisfy the trait's `Sync` bound; it lives on one thread.
|
||||
struct TimedCoder<'a> {
|
||||
inner: &'a dyn ErasureCoder,
|
||||
ns: &'a std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl ErasureCoder for TimedCoder<'_> {
|
||||
fn scheme(&self) -> crate::config::FecScheme {
|
||||
self.inner.scheme()
|
||||
}
|
||||
fn encode(
|
||||
&self,
|
||||
data: &[&[u8]],
|
||||
recovery_count: usize,
|
||||
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
||||
self.inner.encode(data, recovery_count)
|
||||
}
|
||||
fn encode_into(
|
||||
&self,
|
||||
data: &[&[u8]],
|
||||
recovery_count: usize,
|
||||
out: &mut Vec<Vec<u8>>,
|
||||
) -> std::result::Result<(), crate::fec::FecError> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = self.inner.encode_into(data, recovery_count, out);
|
||||
self.ns.fetch_add(
|
||||
t0.elapsed().as_nanos() as u64,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
r
|
||||
}
|
||||
fn reconstruct(
|
||||
&self,
|
||||
data_count: usize,
|
||||
recovery_count: usize,
|
||||
received: &mut [Option<Vec<u8>>],
|
||||
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
|
||||
self.inner.reconstruct(data_count, recovery_count, received)
|
||||
}
|
||||
fn reconstruct_into(
|
||||
&self,
|
||||
recovery_count: usize,
|
||||
data: &mut [&mut [u8]],
|
||||
have: &[bool],
|
||||
recovery: &[(usize, &[u8])],
|
||||
) -> std::result::Result<(), crate::fec::FecError> {
|
||||
self.inner
|
||||
.reconstruct_into(recovery_count, data, have, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). 128 keeps
|
||||
/// the syscall rate ≤ ~3.4k/s even at the ~430k pkt/s the post-2026-07-14 receive path delivers
|
||||
/// (~4.8 Gbps wire), and gives the kernel buffer a deeper drain per pump iteration; the buffers
|
||||
/// cost `RECV_BATCH × RECV_BUF` (~256 KB, client sessions only).
|
||||
const RECV_BATCH: usize = 128;
|
||||
|
||||
impl Session {
|
||||
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
|
||||
config.validate()?;
|
||||
let coder = coder_for(config.fec.scheme);
|
||||
let crypto = config
|
||||
.encrypt
|
||||
.then(|| SessionCrypto::new(&config.key, config.salt, config.role));
|
||||
let crypto = config.encrypt.then(|| {
|
||||
std::sync::Arc::new(SessionCrypto::new(&config.key, config.salt, config.role))
|
||||
});
|
||||
// A receive-side replay window exists exactly when the datagrams are sealed (they carry the
|
||||
// authenticated sequence the window keys on). Both roles receive from their peer.
|
||||
let replay = config.encrypt.then(ReplayWindow::new);
|
||||
@@ -91,10 +263,45 @@ impl Session {
|
||||
recv_count: 0,
|
||||
recv_idx: 0,
|
||||
wire_pool: Vec::new(),
|
||||
// Same opt-in the host's stage logs use; read once — set it before connecting.
|
||||
perf: std::env::var("PUNKTFUNK_PERF")
|
||||
.is_ok_and(|v| v != "0")
|
||||
.then(PumpPerf::default),
|
||||
seal_perf: std::env::var("PUNKTFUNK_PERF")
|
||||
.is_ok_and(|v| v != "0")
|
||||
.then(SealPerf::default),
|
||||
seal_lane: None,
|
||||
// Two-lane sealing of large frames is the default; =1 forces single-lane (the
|
||||
// escape hatch — behavior is byte-identical, this only changes who seals).
|
||||
seal_two_lane: std::env::var("PUNKTFUNK_SEAL_LANES")
|
||||
.map(|v| v != "1")
|
||||
.unwrap_or(true),
|
||||
lane_scratch: Vec::new(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain the receive-path stage timings accumulated since the last call (window semantics —
|
||||
/// the pump reads this once per report interval). `None` when `PUNKTFUNK_PERF` is off.
|
||||
pub fn take_pump_perf(&mut self) -> Option<PumpPerf> {
|
||||
self.perf.as_mut().map(std::mem::take)
|
||||
}
|
||||
|
||||
/// Drain the send-path stage timings accumulated since the last call (window semantics —
|
||||
/// the host send loop reads this once per perf window). `None` when `PUNKTFUNK_PERF` is off.
|
||||
pub fn take_seal_perf(&mut self) -> Option<SealPerf> {
|
||||
self.seal_perf.as_mut().map(std::mem::take)
|
||||
}
|
||||
|
||||
/// Fold externally-timed socket time into [`SealPerf::sock_ns`] — the paced video path
|
||||
/// times its own `send_sealed` chunk calls (they happen behind a `&self` borrow inside the
|
||||
/// pacing closure, where the session can't self-time). No-op when perf is off.
|
||||
pub fn note_sock_ns(&mut self, ns: u64) {
|
||||
if let Some(p) = self.seal_perf.as_mut() {
|
||||
p.sock_ns += ns;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role(&self) -> Role {
|
||||
self.config.role
|
||||
}
|
||||
@@ -199,50 +406,124 @@ impl Session {
|
||||
// nonce counter advances per emitted packet exactly as before (pinned by the
|
||||
// wire-equivalence tests below). Destructure into disjoint field borrows first — the
|
||||
// emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
|
||||
let perf_armed = self.seal_perf.is_some();
|
||||
let fec_ns = std::sync::atomic::AtomicU64::new(0);
|
||||
let mut seal_ns = 0u64;
|
||||
let two_lane = self.seal_two_lane;
|
||||
let Session {
|
||||
packetizer,
|
||||
coder,
|
||||
crypto,
|
||||
next_seq,
|
||||
wire_pool,
|
||||
seal_lane,
|
||||
lane_scratch,
|
||||
..
|
||||
} = self;
|
||||
// Stage timing (SealPerf): the coder shim times FEC, the seal phase times itself.
|
||||
let timed_coder;
|
||||
let coder_ref: &dyn ErasureCoder = if perf_armed {
|
||||
timed_coder = TimedCoder {
|
||||
inner: coder.as_ref(),
|
||||
ns: &fec_ns,
|
||||
};
|
||||
&timed_coder
|
||||
} else {
|
||||
coder.as_ref()
|
||||
};
|
||||
let mut wires = std::mem::take(wire_pool);
|
||||
let mut used = 0usize;
|
||||
let result =
|
||||
packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder.as_ref(), {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
match crypto {
|
||||
Some(c) => {
|
||||
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
c.seal_in_place(seq, &mut wire[8..])?;
|
||||
}
|
||||
None => {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
// Phase 1 — packetize: write each packet's plaintext at its final wire offset
|
||||
// (`seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16)` with crypto on; `header ‖ shard`
|
||||
// off). The nonce counter advances per packet in emission order exactly as before;
|
||||
// sealing itself is a separate pass so it can split across lanes.
|
||||
let seq_base = *next_seq;
|
||||
let encrypting = crypto.is_some();
|
||||
let result = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
});
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
if encrypting {
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
} else {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
result?;
|
||||
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
|
||||
// as the previous `resize_with(packets.len(), ..)` did.
|
||||
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a
|
||||
// two-lane split hands the worker exactly the frame's back half.)
|
||||
wires.truncate(used);
|
||||
// Phase 2 — seal. Large frames split across two lanes (plan Phase 1.5): the worker
|
||||
// seals the back half under nonces `seq_base + i` while this thread seals the front —
|
||||
// byte-identical output to the sequential pass (pinned by the wire-equivalence test).
|
||||
if let Some(c) = crypto {
|
||||
if two_lane && used >= TWO_LANE_MIN_PACKETS && seal_lane.is_none() {
|
||||
*seal_lane = SealLane::spawn(c.clone()); // stays None if spawn fails → single-lane
|
||||
}
|
||||
let mut split_done = false;
|
||||
if two_lane && used >= TWO_LANE_MIN_PACKETS {
|
||||
if let Some(lane) = seal_lane.as_ref() {
|
||||
let half = used / 2;
|
||||
let mut tail = std::mem::take(lane_scratch);
|
||||
tail.extend(wires.drain(half..));
|
||||
let job = SealJob {
|
||||
bufs: tail,
|
||||
seq_base: seq_base.wrapping_add(half as u64),
|
||||
timed: perf_armed,
|
||||
ns: 0,
|
||||
result: Ok(()),
|
||||
};
|
||||
if lane.to_worker.send(job).is_ok() {
|
||||
// Seal the front half while the worker runs; collect BOTH results
|
||||
// before erroring so the lane is always drained and reusable.
|
||||
let t0 = perf_armed.then(std::time::Instant::now);
|
||||
let front = seal_wire_slice(c, &mut wires, seq_base);
|
||||
if let Some(t0) = t0 {
|
||||
seal_ns += t0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
let mut done = lane
|
||||
.from_worker
|
||||
.recv()
|
||||
.map_err(|_| PunktfunkError::Unsupported("seal lane died"))?;
|
||||
seal_ns += done.ns;
|
||||
wires.append(&mut done.bufs);
|
||||
*lane_scratch = done.bufs;
|
||||
front?;
|
||||
done.result?;
|
||||
split_done = true;
|
||||
}
|
||||
// A failed send means the worker is gone — fall through to single-lane.
|
||||
}
|
||||
}
|
||||
if !split_done {
|
||||
let t0 = perf_armed.then(std::time::Instant::now);
|
||||
seal_wire_slice(c, &mut wires, seq_base)?;
|
||||
if let Some(t0) = t0 {
|
||||
seal_ns += t0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.seal_perf.as_mut() {
|
||||
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
|
||||
p.seal_ns += seal_ns;
|
||||
p.frames += 1;
|
||||
p.packets += used as u64;
|
||||
}
|
||||
StatsCounters::add(&self.stats.frames_submitted, 1);
|
||||
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
||||
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
||||
@@ -278,8 +559,12 @@ impl Session {
|
||||
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
|
||||
let wires = self.seal_frame(data, pts_ns, user_flags)?;
|
||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
|
||||
let r = self.send_sealed(&refs);
|
||||
drop(refs); // release the borrow of `wires` before returning the buffers to the pool
|
||||
if let Some(t0) = t0 {
|
||||
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
|
||||
}
|
||||
self.reclaim_wires(wires);
|
||||
r.map(|_| ())
|
||||
}
|
||||
@@ -295,8 +580,12 @@ impl Session {
|
||||
let wires =
|
||||
self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?;
|
||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
|
||||
let r = self.send_sealed(&refs);
|
||||
drop(refs);
|
||||
if let Some(t0) = t0 {
|
||||
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
|
||||
}
|
||||
self.reclaim_wires(wires);
|
||||
r.map(|_| ())
|
||||
}
|
||||
@@ -362,9 +651,14 @@ impl Session {
|
||||
loop {
|
||||
// Refill the ring with one `recvmmsg` batch when the current one is drained.
|
||||
if self.recv_idx >= self.recv_count {
|
||||
let t0 = self.perf.is_some().then(std::time::Instant::now);
|
||||
self.recv_count = self
|
||||
.transport
|
||||
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
||||
if let (Some(p), Some(t0)) = (self.perf.as_mut(), t0) {
|
||||
p.recv_ns += t0.elapsed().as_nanos() as u64;
|
||||
p.batches += 1;
|
||||
}
|
||||
self.recv_idx = 0;
|
||||
if self.recv_count == 0 {
|
||||
return Err(PunktfunkError::NoFrame);
|
||||
@@ -384,6 +678,9 @@ impl Session {
|
||||
// one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
|
||||
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
|
||||
// `recv_scratch` alive across the replay/reassembler calls below.
|
||||
// Perf note: the two `continue`s below (short / undecryptable noise) skip the decrypt
|
||||
// accounting — they are the exception path, not line-rate traffic.
|
||||
let t_dec = self.perf.is_some().then(std::time::Instant::now);
|
||||
let (pkt_range, seq) = match &self.crypto {
|
||||
Some(c) => {
|
||||
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
|
||||
@@ -398,6 +695,9 @@ impl Session {
|
||||
}
|
||||
None => (0..len, None),
|
||||
};
|
||||
if let (Some(p), Some(t)) = (self.perf.as_mut(), t_dec) {
|
||||
p.decrypt_ns += t.elapsed().as_nanos() as u64;
|
||||
}
|
||||
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
|
||||
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
|
||||
// is uniform and cheap.
|
||||
@@ -412,10 +712,16 @@ impl Session {
|
||||
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
||||
// The reassembler validates the packet via its parsed header (`magic`),
|
||||
// ignoring anything that isn't a well-formed video packet.
|
||||
if let Some(frame) = self
|
||||
let t_push = self.perf.is_some().then(std::time::Instant::now);
|
||||
let pushed = self
|
||||
.reassembler
|
||||
.push(pkt, self.coder.as_ref(), &self.stats)?
|
||||
{
|
||||
.push(pkt, self.coder.as_ref(), &self.stats)?;
|
||||
if let (Some(p), Some(t)) = (self.perf.as_mut(), t_push) {
|
||||
p.reasm_ns += t.elapsed().as_nanos() as u64;
|
||||
// Counts datagrams that reached the reassembler (replay-rejected ones don't).
|
||||
p.packets += 1;
|
||||
}
|
||||
if let Some(frame) = pushed {
|
||||
StatsCounters::add(&self.stats.frames_completed, 1);
|
||||
return Ok(frame);
|
||||
}
|
||||
@@ -433,8 +739,8 @@ impl Session {
|
||||
/// (observed live: a stream stuck 6–7 s behind, socket buffers full end to end). Discarding
|
||||
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
|
||||
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
|
||||
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
|
||||
/// outpacing the discard loop indefinitely.
|
||||
/// cap (1024 batches ≈ 131k datagrams ≈ 190 MB at the 128-deep ring) only guards against a
|
||||
/// line-rate sender outpacing the discard loop indefinitely.
|
||||
pub fn flush_backlog(&mut self) -> Result<u64> {
|
||||
if self.config.role != Role::Client {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
@@ -446,7 +752,7 @@ impl Session {
|
||||
self.recv_count = 0;
|
||||
self.recv_idx = 0;
|
||||
if !self.recv_scratch.is_empty() {
|
||||
for _ in 0..4096 {
|
||||
for _ in 0..1024 {
|
||||
let n = self
|
||||
.transport
|
||||
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
||||
@@ -492,10 +798,12 @@ fn seq_of(wire: &[u8]) -> u64 {
|
||||
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
|
||||
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
|
||||
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
|
||||
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
|
||||
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
|
||||
/// could hide; the bitmap costs 4 KiB per session.
|
||||
const REPLAY_WINDOW: u64 = 32768;
|
||||
/// ~125k pkt/s of a 1 Gbps stream; 32768 topped out around ~2 Gbps — which the client now
|
||||
/// exceeds: the 2026-07-14 zero-copy + hardware-AES work measured ~4.8 Gbps wire ≈ 430k pkt/s
|
||||
/// delivered). 131072 covers 120 ms up to ~1.09M pkt/s (≈12 Gbps wire) and is effectively
|
||||
/// unbounded for the sparse input stream, while still bounding how far back a replay could
|
||||
/// hide; the bitmap costs 16 KiB per session.
|
||||
const REPLAY_WINDOW: u64 = 131072;
|
||||
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
||||
|
||||
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
||||
@@ -637,11 +945,12 @@ mod wire_equivalence_tests {
|
||||
|
||||
// shard_payload 64 × max_data_per_block 8: >512 bytes spans FEC blocks.
|
||||
let frames: Vec<Vec<u8>> = vec![
|
||||
pattern(3000), // multi-block + partial tail shard
|
||||
pattern(1024), // exact multiple (2 full blocks)
|
||||
pattern(100), // single block, partial tail
|
||||
Vec::new(), // empty frame → 1 zeroed shard
|
||||
pattern(64), // exactly one full shard
|
||||
pattern(3000), // multi-block + partial tail shard
|
||||
pattern(1024), // exact multiple (2 full blocks)
|
||||
pattern(100), // single block, partial tail
|
||||
Vec::new(), // empty frame → 1 zeroed shard
|
||||
pattern(64), // exactly one full shard
|
||||
pattern(20000), // > TWO_LANE_MIN_PACKETS wire packets → two-lane seal
|
||||
];
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
|
||||
@@ -654,6 +963,15 @@ mod wire_equivalence_tests {
|
||||
// (including a bigger frame after a smaller one and vice versa).
|
||||
opt.reclaim_wires(got);
|
||||
}
|
||||
// The 20000-byte frame (~469 wire packets at shard 64) crosses
|
||||
// TWO_LANE_MIN_PACKETS: the equality above must have held THROUGH the
|
||||
// two-lane split, not via a silent single-lane fallback.
|
||||
if encrypt {
|
||||
assert!(
|
||||
opt.seal_lane.is_some(),
|
||||
"two-lane seal lane should have spawned for the large frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ pub struct Stats {
|
||||
/// send path; raise `net.core.wmem_max` / lower the bitrate, or wait for paced batched sending.
|
||||
pub packets_send_dropped: u64,
|
||||
pub fec_recovered_shards: u64,
|
||||
/// Shards counted into [`fec_recovered_shards`](Self::fec_recovered_shards) that later ARRIVED
|
||||
/// — reordered delivery lets a block reconstruct early from parity, so the still-in-flight
|
||||
/// shards it "recovered" were late, not lost. Loss estimators must net this out
|
||||
/// (`recovered - late`, see [`window_loss_ppm`](crate::quic::window_loss_ppm)) or plain
|
||||
/// reordering reads as packet loss and spooks adaptive FEC + the bitrate controller.
|
||||
/// Deliberately NOT mirrored into the C-ABI `PunktfunkStats` (loss windows run in-core).
|
||||
pub fec_late_shards: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
}
|
||||
@@ -34,6 +41,7 @@ pub struct StatsCounters {
|
||||
pub packets_dropped: AtomicU64,
|
||||
pub packets_send_dropped: AtomicU64,
|
||||
pub fec_recovered_shards: AtomicU64,
|
||||
pub fec_late_shards: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
}
|
||||
@@ -55,6 +63,7 @@ impl StatsCounters {
|
||||
packets_dropped: self.packets_dropped.load(l),
|
||||
packets_send_dropped: self.packets_send_dropped.load(l),
|
||||
fec_recovered_shards: self.fec_recovered_shards.load(l),
|
||||
fec_late_shards: self.fec_late_shards.load(l),
|
||||
bytes_sent: self.bytes_sent.load(l),
|
||||
bytes_received: self.bytes_received.load(l),
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ pub trait Transport: Send + Sync {
|
||||
/// ~1 GSO skb per ≤64 segments instead of one skb per packet. This is the multi-Gbps lever —
|
||||
/// research shows ~2.4× throughput at equal CPU and ~40× fewer syscalls, and that `sendmmsg`
|
||||
/// batching alone is insufficient (it still builds one skb per datagram). The
|
||||
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via `PUNKTFUNK_GSO`,
|
||||
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via
|
||||
/// `PUNKTFUNK_GSO=1` pending pace-aware chunk spacing — see the `gso` module doc — with
|
||||
/// auto-fallback on any GSO error); the default just delegates to [`send_batch`](Self::send_batch),
|
||||
/// correct for loopback and non-Linux. Same lossy, FEC-protected short-count contract as `send_batch`.
|
||||
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Real UDP datagram transport — native sockets, no async runtime.
|
||||
//!
|
||||
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
||||
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
|
||||
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
|
||||
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
|
||||
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
|
||||
@@ -111,8 +111,17 @@ fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// UDP GSO enable state (process-wide). Opt-in via `PUNKTFUNK_GSO` — it's new unsafe hot-path code,
|
||||
/// and the auto-fallback (latch off on any GSO syscall error) covers kernels/paths without support.
|
||||
/// UDP GSO enable state (process-wide). **Opt-in** (`PUNKTFUNK_GSO=1`) — and deliberately so,
|
||||
/// measured three times on 2026-07-14: GSO cuts send-thread CPU ~30% at 1250 Mbps, but its
|
||||
/// line-rate super-buffer trains cost real delivered throughput on a constrained fabric (the
|
||||
/// 2.5GbE-hop pair: peak 2452 → 1909 Mbps, and 0.4% loss at a rate sendmmsg carries clean).
|
||||
/// The third A/B ran WITH pace-aware chunk scaling landed (plan Phase 1.2/1.3 in
|
||||
/// `design/throughput-beyond-1gbps.md`) and reproduced the regression bit-for-bit — the trains
|
||||
/// lose on the hop's queue in the transport path itself (per-AU super-buffers, no video pacer
|
||||
/// involved), so the default stays opt-in on fabric evidence, not on pacing readiness. Revisit
|
||||
/// with a bare-metal Linux host on a clean 10G path. NOTE the gate is value-aware:
|
||||
/// `PUNKTFUNK_GSO=0` explicitly disables (it used to key on env *presence*, so `=0` ENABLED
|
||||
/// it here while disabling Windows USO).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod gso {
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
@@ -123,15 +132,19 @@ mod gso {
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => {
|
||||
let on = std::env::var_os("PUNKTFUNK_GSO").is_some();
|
||||
// Opt-in: on only when PUNKTFUNK_GSO is set to something other than "0".
|
||||
let on = std::env::var("PUNKTFUNK_GSO").is_ok_and(|v| v != "0");
|
||||
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
|
||||
on
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Latch GSO off for the process after a GSO syscall error (unsupported kernel/path).
|
||||
/// Warns once — a mid-session downshift to sendmmsg should be visible, not silent.
|
||||
pub fn disable() {
|
||||
STATE.store(2, Ordering::Relaxed);
|
||||
if STATE.swap(2, Ordering::Relaxed) != 2 {
|
||||
tracing::warn!("Linux UDP GSO unsupported on this path — falling back to sendmmsg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,17 @@ pub trait Encoder: Send {
|
||||
fn reset(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Retarget the encoder's rate control to `bps` (average == max, CBR) **in place** — same
|
||||
/// codec/resolution/fps, only the bitrate and its derived VBV move. Returns `true` when the
|
||||
/// live encoder accepted the change: the reference chain, the in-flight frames and the
|
||||
/// caller's wire-index prediction all survive, so an adaptive-bitrate step costs *nothing* on
|
||||
/// the wire (no IDR, no in-flight forfeit — the whole point vs. a rebuild). `false` = the
|
||||
/// backend can't (or the driver rejected the new rate, e.g. above the codec-level ceiling) —
|
||||
/// the caller falls back to its full rebuild path, which also owns the bitrate clamping.
|
||||
/// Default: no in-place retarget (the libavcodec/software paths).
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
@@ -332,6 +343,19 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
/// direct-NVENC paths (which used to hardwire 1 frame) to parity. Larger values let complex
|
||||
/// frames borrow bits — better rate utilization at the cost of per-frame size variance.
|
||||
pub(crate) fn vbv_frames_env() -> f64 {
|
||||
std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
@@ -452,6 +476,9 @@ impl Encoder for TrackedEncoder {
|
||||
fn reset(&mut self) -> bool {
|
||||
self.inner.reset()
|
||||
}
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
self.inner.reconfigure_bitrate(bps)
|
||||
}
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
@@ -790,6 +817,28 @@ fn open_nvenc_probed(
|
||||
chroma,
|
||||
)?) as Box<dyn Encoder>);
|
||||
}
|
||||
// The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK
|
||||
// path OUT, and a CUDA session quietly loses real RFI + the no-IDR bitrate reconfigure
|
||||
// with nothing in the logs. This bit the Linux packagers once (fixed e89b2f60) and an
|
||||
// ad-hoc host deploy again on 2026-07-14 — say it loudly instead. (Skipped when the
|
||||
// operator explicitly chose libav via PUNKTFUNK_NVENC_DIRECT=0.)
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
if cuda
|
||||
&& !std::env::var("PUNKTFUNK_NVENC_DIRECT")
|
||||
.map(|v| matches!(v.trim(), "0" | "false" | "no" | "off"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Once per process — featureless builds rebuild the encoder on every bitrate step,
|
||||
// and one line is enough to diagnose the build.
|
||||
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"direct-SDK NVENC is NOT compiled into this build (`--features punktfunk-host/nvenc`) \
|
||||
— CUDA frames take the libav path: no RFI loss recovery, and every adaptive-bitrate \
|
||||
step costs an encoder rebuild + IDR"
|
||||
);
|
||||
}
|
||||
}
|
||||
const MIN_PROBE_BPS: u64 = 50_000_000;
|
||||
let mut candidates = vec![bitrate_bps];
|
||||
let cap = codec.max_bitrate_bps();
|
||||
|
||||
@@ -61,6 +61,8 @@ struct EncodeApi {
|
||||
) -> nv::NVENCSTATUS,
|
||||
initialize_encoder:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
|
||||
reconfigure_encoder:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_RECONFIGURE_PARAMS) -> nv::NVENCSTATUS,
|
||||
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
|
||||
get_encode_caps: unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
@@ -187,6 +189,7 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
let api = EncodeApi {
|
||||
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
||||
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||
@@ -294,6 +297,10 @@ pub struct NvencCudaEncoder {
|
||||
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring.
|
||||
rfi_supported: bool,
|
||||
custom_vbv: bool,
|
||||
/// The split-encode mode the live session was initialized with — `reconfigure_bitrate` must
|
||||
/// present the SAME init params as the open (only the config's rate fields may move).
|
||||
/// Meaningless while `inited` is false.
|
||||
split_mode: u32,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for one loss.
|
||||
last_rfi_range: Option<(i64, i64)>,
|
||||
}
|
||||
@@ -361,6 +368,7 @@ impl NvencCudaEncoder {
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
last_rfi_range: None,
|
||||
})
|
||||
}
|
||||
@@ -465,21 +473,11 @@ impl NvencCudaEncoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
|
||||
device: self.cu_ctx,
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
|
||||
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
|
||||
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
|
||||
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
||||
/// retarget re-authors the exact same config with only the bitrate + derived VBV moved.
|
||||
unsafe fn build_config(&self, enc: *mut c_void, bitrate: u64) -> Result<nv::NV_ENC_CONFIG> {
|
||||
// Seed the P1 + ultra-low-latency preset config.
|
||||
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
||||
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
||||
@@ -489,7 +487,7 @@ impl NvencCudaEncoder {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = (api().get_encode_preset_config_ex)(
|
||||
(api().get_encode_preset_config_ex)(
|
||||
enc,
|
||||
self.codec_guid,
|
||||
nv::NV_ENC_PRESET_P1_GUID,
|
||||
@@ -497,10 +495,7 @@ impl NvencCudaEncoder {
|
||||
&mut preset,
|
||||
)
|
||||
.nv_ok()
|
||||
{
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
||||
}
|
||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
||||
let mut cfg = preset.presetCfg;
|
||||
|
||||
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
|
||||
@@ -511,7 +506,9 @@ impl NvencCudaEncoder {
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
if self.custom_vbv {
|
||||
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
|
||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, u32::MAX as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
@@ -621,7 +618,18 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
|
||||
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
|
||||
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
|
||||
/// NVENC call it feeds this into.
|
||||
fn build_init_params(
|
||||
&self,
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
encodeGUID: self.codec_guid,
|
||||
@@ -634,10 +642,36 @@ impl NvencCudaEncoder {
|
||||
frameRateNum: self.fps,
|
||||
frameRateDen: 1,
|
||||
enablePTD: 1,
|
||||
encodeConfig: &mut cfg,
|
||||
encodeConfig: cfg,
|
||||
..Default::default()
|
||||
};
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
init
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
|
||||
device: self.cu_ctx,
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut init = self.build_init_params(&mut cfg, split_mode);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
Ok(()) => Ok(enc),
|
||||
@@ -750,6 +784,10 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
||||
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
|
||||
// rejects it, and the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
|
||||
// Output bitstream pool.
|
||||
for _ in 0..POOL {
|
||||
@@ -1114,6 +1152,50 @@ impl Encoder for NvencCudaEncoder {
|
||||
true
|
||||
}
|
||||
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
if !self.inited {
|
||||
// No live session yet — the lazy init simply opens at the new rate.
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
||||
// encode thread with no NVENC call in flight (the session loop calls this between
|
||||
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
||||
// the synchronous reconfigure call whose `reInitEncodeParams.encodeConfig` points at it.
|
||||
unsafe {
|
||||
let mut cfg = match self.build_config(self.encoder, bps) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"NVENC reconfigure: config re-author failed — falling back to a rebuild");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
||||
reInitEncodeParams: self.build_init_params(&mut cfg, self.split_mode),
|
||||
..Default::default()
|
||||
};
|
||||
// Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
|
||||
// frames and the caller's wire-index prediction survive the retarget.
|
||||
params.set_resetEncoder(0);
|
||||
params.set_forceIDR(0);
|
||||
match (api().reconfigure_encoder)(self.encoder, &mut params).nv_ok() {
|
||||
Ok(()) => {
|
||||
self.bitrate_bps = bps;
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// E.g. the new rate is above the codec-level ceiling — the caller's rebuild
|
||||
// fallback owns the clamp search.
|
||||
tracing::warn!(status = ?e, mbps = bps / 1_000_000,
|
||||
"nvEncReconfigureEncoder rejected — falling back to a rebuild");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -1269,6 +1351,68 @@ mod tests {
|
||||
println!("nvenc_cuda 4:4:4 smoke: {aus} AUs, caps.chroma_444=true");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the Phase 3.2 in-place rate retarget — encode a few frames,
|
||||
/// `reconfigure_bitrate` mid-stream (up AND down), keep encoding, and assert every
|
||||
/// post-reconfigure AU is a P-frame: `nvEncReconfigureEncoder` with `resetEncoder=0` /
|
||||
/// `forceIDR=0` must NOT restart the stream (the whole point vs. the rebuild path). Run:
|
||||
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_reconfigure --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_reconfigure_no_idr() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
let submit_and_poll = |enc: &mut NvencCudaEncoder, range: std::ops::Range<u32>| {
|
||||
let mut keyframes = 0usize;
|
||||
let mut aus = 0usize;
|
||||
for i in range {
|
||||
let frame = nv12_frame(W, H, i);
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
}
|
||||
}
|
||||
(aus, keyframes)
|
||||
};
|
||||
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 0..4);
|
||||
assert!(aus > 0, "no AUs before the reconfigure");
|
||||
assert_eq!(kfs, 1, "exactly the opening IDR before the reconfigure");
|
||||
|
||||
assert!(
|
||||
enc.reconfigure_bitrate(60_000_000),
|
||||
"in-place reconfigure to 60 Mbps must succeed on RTX NVENC"
|
||||
);
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 4..8);
|
||||
assert!(aus > 0, "no AUs after the up-reconfigure");
|
||||
assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");
|
||||
|
||||
assert!(
|
||||
enc.reconfigure_bitrate(10_000_000),
|
||||
"in-place reconfigure down to 10 Mbps must succeed"
|
||||
);
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 8..12);
|
||||
assert!(aus > 0, "no AUs after the down-reconfigure");
|
||||
assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");
|
||||
|
||||
enc.flush().ok();
|
||||
println!("nvenc_cuda reconfigure smoke: 20→60→10 Mbps in place, zero IDRs");
|
||||
}
|
||||
|
||||
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
|
||||
/// Needs no GPU session (it short-circuits on the null encoder / range checks), so it runs in the
|
||||
/// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA
|
||||
|
||||
@@ -192,6 +192,12 @@ pub struct VulkanVideoEncoder {
|
||||
// --- rate control (CBR), rebuilt-safe ---
|
||||
bitrate: u64,
|
||||
fps: u32,
|
||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
||||
/// `bitrate` — which must keep naming the session's CURRENT state, because every begin-coding
|
||||
/// declares it (the spec requires the declared state to match).
|
||||
pending_bitrate: Option<u64>,
|
||||
|
||||
// --- state ---
|
||||
width: u32,
|
||||
@@ -654,6 +660,7 @@ impl VulkanVideoEncoder {
|
||||
compute_pool,
|
||||
bitrate,
|
||||
fps,
|
||||
pending_bitrate: None,
|
||||
width: w,
|
||||
height: h,
|
||||
render_w: rw,
|
||||
@@ -901,6 +908,15 @@ impl VulkanVideoEncoder {
|
||||
let nv12_view = self.frames[slot].nv12_view;
|
||||
|
||||
// ---- 1. decide frame type + reference (RFI) ----
|
||||
// Mid-stream rate retarget (`reconfigure_bitrate`): a first frame installs its RC state
|
||||
// fresh (RESET + ENCODE_RATE_CONTROL in the record fns), so a pending rate folds straight
|
||||
// into it; mid-stream it stays pending — the record fns emit an ENCODE_RATE_CONTROL
|
||||
// control command against the declared current state, and step 5 promotes it.
|
||||
if self.first_frame {
|
||||
if let Some(nb) = self.pending_bitrate.take() {
|
||||
self.bitrate = nb;
|
||||
}
|
||||
}
|
||||
let mut is_idr = self.first_frame || self.force_kf;
|
||||
let mut ref_slot = self.prev_slot;
|
||||
let mut recovery = false;
|
||||
@@ -1202,6 +1218,15 @@ impl VulkanVideoEncoder {
|
||||
self.enc_count += 1;
|
||||
self.first_frame = false;
|
||||
self.force_kf = false;
|
||||
if let Some(nb) = self.pending_bitrate.take() {
|
||||
// The retarget control command is recorded (execution follows submission order): the
|
||||
// session's RC state IS the new rate from this frame on — later begins declare it.
|
||||
self.bitrate = nb;
|
||||
tracing::info!(
|
||||
mbps = nb / 1_000_000,
|
||||
"vulkan-encode: rate control retargeted in place (no IDR)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1436,6 +1461,27 @@ impl VulkanVideoEncoder {
|
||||
);
|
||||
ctrl.p_next = rc_ptr;
|
||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
||||
} else if let Some(nb) = self.pending_bitrate {
|
||||
// Mid-stream retarget (`reconfigure_bitrate`): `begin` above declared the session's
|
||||
// CURRENT rate-control state (the spec requires the match); this control command
|
||||
// installs the NEW rate — the same CBR shape with only the bitrate moved. No RESET,
|
||||
// no IDR: the DPB and reference chain carry straight on. `record_submit` promotes
|
||||
// `nb` into `self.bitrate` after recording, so later begins declare the new state.
|
||||
let rc_layer2 = [vk::VideoEncodeRateControlLayerInfoKHR::default()
|
||||
.average_bitrate(nb)
|
||||
.max_bitrate(nb)
|
||||
.frame_rate_numerator(self.fps)
|
||||
.frame_rate_denominator(1)];
|
||||
let mut rc2 = vk::VideoEncodeRateControlInfoKHR::default()
|
||||
.rate_control_mode(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
|
||||
.layers(&rc_layer2)
|
||||
.virtual_buffer_size_in_ms(1000)
|
||||
.initial_virtual_buffer_size_in_ms(500);
|
||||
rc2.p_next = &h265_rc as *const _ as *const c_void;
|
||||
let mut ctrl = vk::VideoCodingControlInfoKHR::default()
|
||||
.flags(vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL);
|
||||
ctrl.p_next = &rc2 as *const _ as *const c_void;
|
||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
||||
}
|
||||
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||
@@ -1674,6 +1720,25 @@ impl VulkanVideoEncoder {
|
||||
);
|
||||
ctrl.p_next = rc_ptr;
|
||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
||||
} else if let Some(nb) = self.pending_bitrate {
|
||||
// Mid-stream retarget (`reconfigure_bitrate`) — see the HEVC twin for the state
|
||||
// discipline (begin declares CURRENT, this control installs NEW, `record_submit`
|
||||
// promotes after recording). No RESET, no IDR.
|
||||
let rc_layer2 = [vk::VideoEncodeRateControlLayerInfoKHR::default()
|
||||
.average_bitrate(nb)
|
||||
.max_bitrate(nb)
|
||||
.frame_rate_numerator(self.fps)
|
||||
.frame_rate_denominator(1)];
|
||||
let mut rc2 = vk::VideoEncodeRateControlInfoKHR::default()
|
||||
.rate_control_mode(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
|
||||
.layers(&rc_layer2)
|
||||
.virtual_buffer_size_in_ms(1000)
|
||||
.initial_virtual_buffer_size_in_ms(500);
|
||||
rc2.p_next = &av1_rc as *const _ as *const c_void;
|
||||
let mut ctrl = vk::VideoCodingControlInfoKHR::default()
|
||||
.flags(vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL);
|
||||
ctrl.p_next = &rc2 as *const _ as *const c_void;
|
||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
||||
}
|
||||
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||
@@ -1832,6 +1897,16 @@ impl Encoder for VulkanVideoEncoder {
|
||||
self.poc = 0;
|
||||
self.slot_wire.iter_mut().for_each(|s| *s = -1);
|
||||
self.slot_poc.iter_mut().for_each(|s| *s = -1);
|
||||
// A pending `reconfigure_bitrate` rate deliberately survives: the restart's first frame
|
||||
// folds it into the fresh RESET + rate-control install.
|
||||
true
|
||||
}
|
||||
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
// The RC block is re-declared on every recorded frame, so the retarget is just a staged
|
||||
// rate: the next `record_submit` emits an ENCODE_RATE_CONTROL control command carrying it
|
||||
// — no session churn, no IDR. Same floor as `open` (a 0-rate CBR layer is rejected).
|
||||
self.pending_bitrate = Some(bps.max(1_000_000));
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -1328,6 +1328,14 @@ impl AmfEncoder {
|
||||
!ltr_disabled() && matches!(self.codec, Codec::H264 | Codec::H265)
|
||||
}
|
||||
|
||||
/// The VBV/HRD buffer (bits) at `bps`: ~1 frame interval, `PUNKTFUNK_VBV_FRAMES`-scaled — the
|
||||
/// same shape every backend ships. Shared by [`apply_static_props`](Self::apply_static_props)
|
||||
/// and [`Encoder::reconfigure_bitrate`] so a dynamic retarget rescales the buffer it opened with.
|
||||
fn vbv_bits(&self, bps: u64) -> i64 {
|
||||
((bps as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, i32::MAX as f64) as i64
|
||||
}
|
||||
|
||||
/// Apply the static encoder configuration (design §3.4 — the native mirror of the ffmpeg
|
||||
/// opts block in `open_win_encoder`). Called before `Init`, and again on a `reset()`
|
||||
/// re-`Init` (Terminate does not guarantee property retention across every driver).
|
||||
@@ -1357,14 +1365,12 @@ impl AmfEncoder {
|
||||
true,
|
||||
)?;
|
||||
// ~1-frame VBV (PUNKTFUNK_VBV_FRAMES override, same knob as the ffmpeg path).
|
||||
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits = ((self.bitrate_bps as f64 / self.fps.max(1) as f64) * vbv_frames as f64)
|
||||
.clamp(1.0, i32::MAX as f64) as i64;
|
||||
set_prop(comp, p.vbv_size, AmfVariant::from_i64(vbv_bits), false)?;
|
||||
set_prop(
|
||||
comp,
|
||||
p.vbv_size,
|
||||
AmfVariant::from_i64(self.vbv_bits(self.bitrate_bps)),
|
||||
false,
|
||||
)?;
|
||||
set_prop(comp, p.enforce_hrd, AmfVariant::from_bool(true), false)?;
|
||||
set_prop(comp, p.filler_data, AmfVariant::from_bool(false), false)?;
|
||||
// Latency-first quality; low-latency submission mode (optional — newer VCN/drivers).
|
||||
@@ -2499,6 +2505,47 @@ impl Encoder for AmfEncoder {
|
||||
true
|
||||
}
|
||||
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
let bps_i = bps.min(i64::MAX as u64) as i64;
|
||||
let vbv = self.vbv_bits(bps);
|
||||
let Some(inner) = self.inner.as_ref() else {
|
||||
// Nothing live yet — the lazy open applies the new rate via `apply_static_props`.
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
};
|
||||
// `TargetBitrate`/`PeakBitrate`/`VBVBufferSize` are DYNAMIC AMF properties (runtime-
|
||||
// changeable on AVC/HEVC/AV1 alike): a SetProperty on the live component retargets the
|
||||
// rate controller with no Terminate/re-Init — the reference chain, LTR slots and
|
||||
// in-flight frames all survive (no IDR).
|
||||
// SAFETY: `inner.comp.0` is the live component, used only on this thread with no AMF
|
||||
// call in flight (the session loop is synchronous); `set_prop` is a prefix-vtable call.
|
||||
let applied = unsafe {
|
||||
let p = &self.props;
|
||||
let comp = inner.comp.0;
|
||||
let ok = set_prop(comp, p.target_bitrate, AmfVariant::from_i64(bps_i), false)
|
||||
.unwrap_or(false)
|
||||
&& set_prop(comp, p.peak_bitrate, AmfVariant::from_i64(bps_i), false)
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
// Rescale the VBV with the rate. Optional, like at open — a driver that declines
|
||||
// keeps the old buffer (a size mismatch the HRD absorbs), not worth a rebuild.
|
||||
let _ = set_prop(comp, p.vbv_size, AmfVariant::from_i64(vbv), false);
|
||||
}
|
||||
ok
|
||||
};
|
||||
if !applied {
|
||||
// A half-applied pair doesn't matter: the caller's rebuild fallback re-authors
|
||||
// everything from scratch.
|
||||
tracing::warn!(
|
||||
mbps = bps / 1_000_000,
|
||||
"AMF declined the dynamic bitrate retarget — falling back to a rebuild"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.bitrate_bps = bps; // future reset()/re-Init paths re-apply the new rate
|
||||
true
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
let Some(inner) = self.inner.as_mut() else {
|
||||
return Ok(());
|
||||
|
||||
@@ -74,6 +74,8 @@ struct EncodeApi {
|
||||
) -> nv::NVENCSTATUS,
|
||||
initialize_encoder:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
|
||||
reconfigure_encoder:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_RECONFIGURE_PARAMS) -> nv::NVENCSTATUS,
|
||||
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
|
||||
get_encode_caps: unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
@@ -207,6 +209,7 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
Ok(EncodeApi {
|
||||
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
||||
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||
@@ -454,6 +457,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
|
||||
rfi_supported: bool,
|
||||
custom_vbv: bool,
|
||||
/// The split-encode mode + async-retrieve flag the live session was initialized with —
|
||||
/// `reconfigure_bitrate` must present the SAME init params as the open (only the config's
|
||||
/// rate fields may move). Meaningless while `inited` is false.
|
||||
split_mode: u32,
|
||||
session_async: bool,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
|
||||
/// loss event (the client resends until it sees recovery).
|
||||
last_rfi_range: Option<(i64, i64)>,
|
||||
@@ -526,6 +534,8 @@ impl NvencD3d11Encoder {
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
session_async: false,
|
||||
last_rfi_range: None,
|
||||
init_device: ptr::null_mut(),
|
||||
session_units: 0,
|
||||
@@ -679,28 +689,11 @@ impl NvencD3d11Encoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
unsafe fn try_open_session(
|
||||
&self,
|
||||
device: &ID3D11Device,
|
||||
bitrate: u64,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
|
||||
device: device.as_raw(),
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
|
||||
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
|
||||
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
|
||||
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
||||
/// retarget re-authors the exact same config with only the bitrate + derived VBV moved.
|
||||
unsafe fn build_config(&self, enc: *mut c_void, bitrate: u64) -> Result<nv::NV_ENC_CONFIG> {
|
||||
// Seed the P1 + ultra-low-latency preset config.
|
||||
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
||||
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
||||
@@ -710,7 +703,7 @@ impl NvencD3d11Encoder {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = (api().get_encode_preset_config_ex)(
|
||||
(api().get_encode_preset_config_ex)(
|
||||
enc,
|
||||
self.codec_guid,
|
||||
nv::NV_ENC_PRESET_P1_GUID,
|
||||
@@ -718,10 +711,7 @@ impl NvencD3d11Encoder {
|
||||
&mut preset,
|
||||
)
|
||||
.nv_ok()
|
||||
{
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
||||
}
|
||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
||||
let mut cfg = preset.presetCfg;
|
||||
|
||||
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
|
||||
@@ -734,7 +724,9 @@ impl NvencD3d11Encoder {
|
||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
||||
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
||||
if self.custom_vbv {
|
||||
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
|
||||
// ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV).
|
||||
let vbv = ((bitrate as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
|
||||
.clamp(1.0, u32::MAX as f64) as u32;
|
||||
cfg.rcParams.vbvBufferSize = vbv;
|
||||
cfg.rcParams.vbvInitialDelay = vbv;
|
||||
}
|
||||
@@ -895,7 +887,19 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Author the `NV_ENC_INITIALIZE_PARAMS` pointing at `cfg`. Shared by [`try_open_session`]
|
||||
/// and [`Encoder::reconfigure_bitrate`] — a reconfigure must present the SAME init params as
|
||||
/// the open. The returned struct borrows `cfg` raw; the caller keeps `cfg` alive across the
|
||||
/// NVENC call it feeds this into.
|
||||
fn build_init_params(
|
||||
&self,
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
encodeGUID: self.codec_guid,
|
||||
@@ -911,11 +915,44 @@ impl NvencD3d11Encoder {
|
||||
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
|
||||
// instead of `lock_bitstream` blocking the submit thread.
|
||||
enableEncodeAsync: enable_async as u32,
|
||||
encodeConfig: &mut cfg,
|
||||
encodeConfig: cfg,
|
||||
..Default::default()
|
||||
};
|
||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
init
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
unsafe fn try_open_session(
|
||||
&self,
|
||||
device: &ID3D11Device,
|
||||
bitrate: u64,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
|
||||
device: device.as_raw(),
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
(api().open_encode_session_ex)(&mut params, &mut enc)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut init = self.build_init_params(&mut cfg, split_mode, enable_async);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
Ok(()) => Ok(enc),
|
||||
@@ -1069,6 +1106,12 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
||||
// the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
self.session_async = use_async;
|
||||
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
||||
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||
// mode (a split session occupies one hardware session per engine).
|
||||
@@ -1619,6 +1662,55 @@ impl Encoder for NvencD3d11Encoder {
|
||||
true
|
||||
}
|
||||
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
if !self.inited {
|
||||
// No live session yet — the lazy init simply opens at the new rate.
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
||||
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
||||
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
||||
// bitstreams). `build_config` only queries the preset on that session; `cfg` outlives the
|
||||
// synchronous reconfigure call whose `reInitEncodeParams.encodeConfig` points at it.
|
||||
unsafe {
|
||||
let mut cfg = match self.build_config(self.encoder, bps) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"NVENC reconfigure: config re-author failed — falling back to a rebuild");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
|
||||
version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
|
||||
reInitEncodeParams: self.build_init_params(
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
self.session_async,
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
// Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
|
||||
// frames and the caller's wire-index prediction survive the retarget.
|
||||
params.set_resetEncoder(0);
|
||||
params.set_forceIDR(0);
|
||||
match (api().reconfigure_encoder)(self.encoder, &mut params).nv_ok() {
|
||||
Ok(()) => {
|
||||
self.bitrate_bps = bps;
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// E.g. the new rate is above the codec-level ceiling — the caller's rebuild
|
||||
// fallback owns the clamp search.
|
||||
tracing::warn!(status = ?e, mbps = bps / 1_000_000,
|
||||
"nvEncReconfigureEncoder rejected — falling back to a rebuild");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -1874,6 +1966,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.173`): the Phase 3.2 in-place rate retarget — encode a few frames,
|
||||
/// `reconfigure_bitrate` mid-stream (up AND down), keep encoding, and assert every
|
||||
/// post-reconfigure AU is a P-frame (`nvEncReconfigureEncoder` with `resetEncoder=0` /
|
||||
/// `forceIDR=0` must NOT restart the stream). The Windows twin of the Linux backend's
|
||||
/// `nvenc_cuda_reconfigure_no_idr`. Run:
|
||||
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_reconfigure --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
|
||||
fn nvenc_reconfigure_no_idr() {
|
||||
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
// SAFETY: (test-only) same straight-line D3D11/DXGI setup as `encode_pattern`.
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
|
||||
let mut adapter = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters1(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc1().expect("adapter desc");
|
||||
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
|
||||
adapter = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let adapter = adapter.expect("no hardware DXGI adapter");
|
||||
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
|
||||
|
||||
let bytes = probe_pattern(W as usize, H as usize);
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: bytes.as_ptr() as *const _,
|
||||
SysMemPitch: W * 4,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut tex = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
||||
.expect("pattern texture");
|
||||
let tex = tex.expect("null pattern texture");
|
||||
|
||||
let mut enc = NvencD3d11Encoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Bgra,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("NVENC open");
|
||||
|
||||
let submit_and_poll = |enc: &mut NvencD3d11Encoder, range: std::ops::Range<u64>| {
|
||||
let mut keyframes = 0usize;
|
||||
let mut aus = 0usize;
|
||||
for i in range {
|
||||
let frame = CapturedFrame {
|
||||
width: W,
|
||||
height: H,
|
||||
pts_ns: i * 16_666_667,
|
||||
format: PixelFormat::Bgra,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
}),
|
||||
};
|
||||
enc.submit_indexed(&frame, i as u32).expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
}
|
||||
}
|
||||
enc.flush().ok();
|
||||
while let Ok(Some(au)) = enc.poll() {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
}
|
||||
(aus, keyframes)
|
||||
};
|
||||
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 0..4);
|
||||
assert!(aus > 0, "no AUs before the reconfigure");
|
||||
assert_eq!(kfs, 1, "exactly the opening IDR before the reconfigure");
|
||||
|
||||
assert!(
|
||||
enc.reconfigure_bitrate(60_000_000),
|
||||
"in-place reconfigure to 60 Mbps must succeed on RTX NVENC"
|
||||
);
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 4..8);
|
||||
assert!(aus > 0, "no AUs after the up-reconfigure");
|
||||
assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");
|
||||
|
||||
assert!(
|
||||
enc.reconfigure_bitrate(10_000_000),
|
||||
"in-place reconfigure down to 10 Mbps must succeed"
|
||||
);
|
||||
let (aus, kfs) = submit_and_poll(&mut enc, 8..12);
|
||||
assert!(aus > 0, "no AUs after the down-reconfigure");
|
||||
assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");
|
||||
|
||||
println!("nvenc (Windows) reconfigure smoke: 20→60→10 Mbps in place, zero IDRs");
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
|
||||
/// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT
|
||||
/// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct VideoPacketizer {
|
||||
frame_index: u32,
|
||||
/// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source).
|
||||
seq: u32,
|
||||
/// Persistent GF(2⁸) coder so its `(k, m)` Cauchy-matrix cache survives across frames
|
||||
/// (plan Phase 1.4) — a stream's block shape only moves with frame size.
|
||||
coder: Gf8Coder,
|
||||
}
|
||||
|
||||
impl VideoPacketizer {
|
||||
@@ -65,6 +68,7 @@ impl VideoPacketizer {
|
||||
min_fec: min_fec as usize,
|
||||
frame_index: 0,
|
||||
seq: 0,
|
||||
coder: Gf8Coder::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +162,7 @@ impl VideoPacketizer {
|
||||
let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
|
||||
let parity = if m > 0 {
|
||||
let refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect();
|
||||
Gf8Coder.encode(&refs, m).unwrap_or_default()
|
||||
self.coder.encode(&refs, m).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -328,7 +332,9 @@ mod tests {
|
||||
// Drop data shard 1; reconstruct from the rest via the same Cauchy coder.
|
||||
let mut received: Vec<Option<Vec<u8>>> = pkts.iter().map(|p| Some(p.clone())).collect();
|
||||
received[1] = None;
|
||||
let recovered = Gf8Coder.reconstruct(k, m, &mut received).unwrap();
|
||||
let recovered = Gf8Coder::default()
|
||||
.reconstruct(k, m, &mut received)
|
||||
.unwrap();
|
||||
// The recovered shard equals the original data shard's RS-covered bytes: its flags
|
||||
// byte (offset 24) is PIC (middle shard), proving the NV header recovers correctly.
|
||||
assert_eq!(recovered[1][24], FLAG_PIC);
|
||||
|
||||
@@ -473,6 +473,11 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualsense.rs"]
|
||||
pub mod dualsense;
|
||||
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
||||
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
||||
pub mod dualsense_edge_windows;
|
||||
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
@@ -485,8 +490,8 @@ pub mod dualsense_windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualshock4.rs"]
|
||||
pub mod dualshock4;
|
||||
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend
|
||||
/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
|
||||
/// Transport-independent DualShock 4 HID codec, shared by the Linux UHID backend ([`dualshock4`])
|
||||
/// and the Windows UMDF-driver backend ([`dualshock4_windows`]).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/dualshock4_proto.rs"]
|
||||
pub mod dualshock4_proto;
|
||||
@@ -506,15 +511,27 @@ pub mod gamepad;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/gamepad_raii.rs"]
|
||||
mod gamepad_raii;
|
||||
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]) used by every backend manager on
|
||||
/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry.
|
||||
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
|
||||
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
|
||||
/// retry.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/pad_gate.rs"]
|
||||
pub mod pad_gate;
|
||||
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
|
||||
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
|
||||
/// manager used to copy-paste (G12).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/pad_slots.rs"]
|
||||
pub mod pad_slots;
|
||||
/// 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;
|
||||
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
|
||||
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/steam_deck_windows.rs"]
|
||||
pub mod steam_deck_windows;
|
||||
/// 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`).
|
||||
@@ -522,8 +539,9 @@ pub mod steam_controller;
|
||||
#[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")]
|
||||
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
|
||||
/// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[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.
|
||||
@@ -538,6 +556,21 @@ pub mod steam_remap;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_usbip.rs"]
|
||||
pub mod steam_usbip;
|
||||
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/switch_pro.rs"]
|
||||
pub mod switch_pro;
|
||||
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
||||
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/switch_proto.rs"]
|
||||
pub mod switch_proto;
|
||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/uhid_manager.rs"]
|
||||
pub mod uhid_manager;
|
||||
/// 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 {
|
||||
|
||||
@@ -13,18 +13,16 @@
|
||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||
|
||||
use super::dualsense_proto::{
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_FEATURE_CALIBRATION,
|
||||
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
|
||||
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
|
||||
ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
@@ -35,6 +33,8 @@ 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; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
@@ -45,9 +45,45 @@ fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the
|
||||
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound
|
||||
/// `hid-playstation` interface).
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
pub struct DsUhidIdentity {
|
||||
product: u32,
|
||||
rdesc: &'static [u8],
|
||||
/// Device name prefix ("Punktfunk <name> <index>").
|
||||
name: &'static str,
|
||||
/// Path token for the phys string ("punktfunk/<phys>/<index>").
|
||||
phys: &'static str,
|
||||
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
|
||||
slug: &'static str,
|
||||
}
|
||||
|
||||
impl DsUhidIdentity {
|
||||
pub const fn dualsense() -> DsUhidIdentity {
|
||||
DsUhidIdentity {
|
||||
product: DS_PRODUCT,
|
||||
rdesc: DUALSENSE_RDESC,
|
||||
name: "DualSense",
|
||||
phys: "dualsense",
|
||||
slug: "ds",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn dualsense_edge() -> DsUhidIdentity {
|
||||
DsUhidIdentity {
|
||||
product: DS_EDGE_PRODUCT,
|
||||
rdesc: DUALSENSE_EDGE_RDESC,
|
||||
name: "DualSense Edge",
|
||||
phys: "dualsense-edge",
|
||||
slug: "dsedge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual DualSense / DualSense Edge backed by `/dev/uhid` (hand-rolled codec — no bindgen,
|
||||
/// mirroring the uinput pad's style). Dropping it destroys the device (the kernel tears down the
|
||||
/// bound `hid-playstation` interface).
|
||||
pub struct DualSensePad {
|
||||
fd: File,
|
||||
seq: u8,
|
||||
@@ -55,8 +91,9 @@ pub struct DualSensePad {
|
||||
}
|
||||
|
||||
impl DualSensePad {
|
||||
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique).
|
||||
pub fn open(index: u8) -> Result<DualSensePad> {
|
||||
/// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
|
||||
/// make the device name/uniq unique).
|
||||
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
@@ -66,24 +103,28 @@ impl DualSensePad {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
||||
ds.send_create2(index).context("UHID_CREATE2 DualSense")?;
|
||||
ds.send_create2(index, id)
|
||||
.context("UHID_CREATE2 DualSense")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
/// Send UHID_CREATE2 under `id`'s identity. The uniq written here is cosmetic:
|
||||
/// `hid-playstation` replaces it with the MAC from the pairing feature report (see
|
||||
/// [`ds_pairing_reply`]) as soon as it binds.
|
||||
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualSense {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(id.rdesc.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&DS_PRODUCT.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&id.product.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + DUALSENSE_RDESC.len()].copy_from_slice(DUALSENSE_RDESC); // rd_data
|
||||
ev[280..280 + id.rdesc.len()].copy_from_slice(id.rdesc); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -125,15 +166,25 @@ impl DualSensePad {
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// Per-pad MAC: hid-playstation adopts it as the HID uniq, and SDL/Steam
|
||||
// dedup controllers by that serial (see `ds_pairing_reply`).
|
||||
let pairing = ds_pairing_reply(pad);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x05 => DS_FEATURE_CALIBRATION,
|
||||
0x09 => DS_FEATURE_PAIRING,
|
||||
0x09 => &pairing,
|
||||
0x20 => DS_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
UHID_SET_REPORT => {
|
||||
// Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s
|
||||
// timeout. Nothing to parse: every known DualSense writer sends its feedback
|
||||
// as OUTPUT reports (handled above), never SET_REPORT.
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
@@ -153,6 +204,18 @@ impl DualSensePad {
|
||||
.context("write 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());
|
||||
// uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10].
|
||||
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("write UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualSensePad {
|
||||
@@ -163,201 +226,427 @@ impl Drop for DualSensePad {
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the rich-controller analog of
|
||||
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
|
||||
///
|
||||
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate*
|
||||
/// rich-input plane ([`apply_rich`](Self::apply_rich)) from the button/stick frames
|
||||
/// ([`handle`](Self::handle)). So the manager keeps each pad's full [`DsState`] and re-emits the
|
||||
/// merged report whenever either source changes. [`pump`](Self::pump) services the kernel
|
||||
/// handshake and routes a game's feedback back out: motor rumble on the universal plane, the rich
|
||||
/// LED/player-LED/trigger feedback on the HID-output plane.
|
||||
pub struct DualSenseManager {
|
||||
pads: Vec<Option<DualSensePad>>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
|
||||
state: Vec<DsState>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
|
||||
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
|
||||
hidout_dedup: Vec<HidoutDedup>,
|
||||
/// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which
|
||||
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
|
||||
last_write: Vec<Instant>,
|
||||
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
||||
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle-
|
||||
/// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`].
|
||||
pub struct DsLinuxProto {
|
||||
/// 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 {
|
||||
fn default() -> DualSenseManager {
|
||||
DualSenseManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualSenseManager {
|
||||
pub fn new() -> DualSenseManager {
|
||||
DualSenseManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
gate: PadGate::new(),
|
||||
impl Default for DsLinuxProto {
|
||||
fn default() -> DsLinuxProto {
|
||||
DsLinuxProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualSense)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
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 (DualSense)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.hidout_dedup[i].clear();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
impl PadProto for DsLinuxProto {
|
||||
type Pad = DualSensePad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense";
|
||||
const DEVICE: &'static str = "DualSense";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad (a controller must have
|
||||
/// 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, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
let _ = pad.write_state(&st);
|
||||
}
|
||||
// Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used
|
||||
// pad emits no extra reports — the heartbeat only fills genuine input-silence gaps.
|
||||
self.last_write[idx] = Instant::now();
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||
/// come on the rich-input plane and must survive a button-only frame).
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap`. A real DualSense
|
||||
/// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton /
|
||||
/// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an
|
||||
/// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending
|
||||
/// the current state is idempotent (a stale-but-correct frame, never a phantom input);
|
||||
/// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match DualSensePad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (UHID hid-playstation)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
|
||||
/// is invoked `(index, low, high)` only when the motor level *changes* (the universal 0xCA
|
||||
/// plane — both backends use it); `hidout` is invoked for each DualSense-only rich feedback
|
||||
/// event (lightbar / player LEDs / adaptive triggers — the 0xCD plane). Call frequently:
|
||||
/// the kernel blocks `hid-playstation` init until its GET_REPORTs are answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service(i as u8);
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (the game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
||||
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
||||
/// 0xCA plane, the rich lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the rich-controller analog of
|
||||
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
|
||||
///
|
||||
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate*
|
||||
/// rich-input plane (`apply_rich`) from the button/stick frames (`handle`); the shared
|
||||
/// [`UhidManager`] keeps each pad's full [`DsState`], re-emits the merged report whenever either
|
||||
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
|
||||
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
|
||||
pub type DualSenseManager = UhidManager<DsLinuxProto>;
|
||||
|
||||
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
|
||||
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
|
||||
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
|
||||
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
|
||||
/// stop vanishing). No remap config: every paddle has a native home.
|
||||
///
|
||||
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
|
||||
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
|
||||
/// Steam Input read the report off hidraw and see them on any kernel.
|
||||
#[derive(Default)]
|
||||
pub struct DsEdgeLinuxProto;
|
||||
|
||||
impl PadProto for DsEdgeLinuxProto {
|
||||
type Pad = DualSensePad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense Edge";
|
||||
const DEVICE: &'static str = "DualSense Edge";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense Edge created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
||||
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
|
||||
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
|
||||
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
|
||||
/// [`parse_ds_output`] already handles).
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
|
||||
/// client declares for a paddle-bearing physical controller.
|
||||
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// evdev nodes whose input-device name contains `name`: (full name, /dev/input/eventN).
|
||||
fn find_nodes(name: &str) -> Vec<(String, String)> {
|
||||
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for line in s.lines() {
|
||||
if let Some(n) = line.strip_prefix("N: Name=") {
|
||||
cur = n.trim_matches('"').to_string();
|
||||
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
||||
if cur.contains(name) {
|
||||
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
|
||||
out.push((cur.clone(), format!("/dev/input/{ev}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the evdev at `node` advertises EV_FF (0x15) — the rumble-capable gamepad node
|
||||
/// (the touchpad / motion / headset siblings don't).
|
||||
fn has_ff(node: &str) -> bool {
|
||||
let Ok(f) = std::fs::OpenOptions::new().read(true).open(node) else {
|
||||
return false;
|
||||
};
|
||||
let mut bits = [0u8; 8];
|
||||
// EVIOCGBIT(0, 8): the device's event-type bitmap.
|
||||
let req: libc::c_ulong = (2 << 30) | (8 << 16) | (0x45 << 8) | 0x20;
|
||||
// SAFETY: EVIOCGBIT(0) copies at most 8 bytes (EV_MAX/8 < 8) into the live `bits` buffer
|
||||
// behind the valid evdev fd `f`; the kernel never writes past the ioctl's size argument.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, bits.as_mut_ptr()) };
|
||||
rc >= 0 && (bits[0x15 / 8] >> (0x15 % 8)) & 1 == 1
|
||||
}
|
||||
|
||||
/// Upload an FF_RUMBLE effect on `node` and play it, exactly like SDL's evdev haptic backend.
|
||||
/// Returns the OPEN fd with the id — closing the fd erases the process's effects (stopping
|
||||
/// the rumble), so the caller must hold it while asserting.
|
||||
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(node)?;
|
||||
// struct ff_effect (48 B): type u16, id s16, direction u16, trigger, replay{len,delay},
|
||||
// pad to 16, union (ff_rumble_effect { strong, weak }).
|
||||
let mut eff = [0u8; 48];
|
||||
eff[0..2].copy_from_slice(&0x50u16.to_ne_bytes()); // FF_RUMBLE
|
||||
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
||||
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
||||
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
||||
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
||||
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
||||
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
||||
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
||||
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
||||
if rc < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
||||
// struct input_event (24 B on 64-bit): timeval 16, type u16, code u16, value s32.
|
||||
let mut ev = [0u8; 24];
|
||||
ev[16..18].copy_from_slice(&0x15u16.to_ne_bytes()); // EV_FF
|
||||
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
||||
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
||||
f.write_all(&ev)?;
|
||||
Ok((f, id))
|
||||
}
|
||||
|
||||
/// `(HID_NAME, HID_UNIQ, /dev/hidrawN)` for every hidraw class device.
|
||||
fn hidraw_devices() -> Vec<(String, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(dir) = std::fs::read_dir("/sys/class/hidraw") else {
|
||||
return out;
|
||||
};
|
||||
for e in dir.flatten() {
|
||||
let ue = std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default();
|
||||
let field = |k: &str| {
|
||||
ue.lines()
|
||||
.find_map(|l| l.strip_prefix(k))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
out.push((
|
||||
field("HID_NAME="),
|
||||
field("HID_UNIQ="),
|
||||
format!("/dev/{}", e.file_name().to_string_lossy()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Service `pad` for `ms`, accumulating every captured feedback pass (all rumble levels in
|
||||
/// order + all rich events) while keeping the input heartbeat going.
|
||||
fn collect(pad: &mut DualSensePad, st: &DsState, ms: u64) -> (Vec<(u16, u16)>, Vec<HidOutput>) {
|
||||
let start = Instant::now();
|
||||
let (mut levels, mut hidout) = (Vec::new(), Vec::<HidOutput>::new());
|
||||
while start.elapsed() < Duration::from_millis(ms) {
|
||||
let fb = pad.service(0);
|
||||
levels.extend(fb.rumble);
|
||||
hidout.extend(fb.hidout);
|
||||
let _ = pad.write_state(st);
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
(levels, hidout)
|
||||
}
|
||||
|
||||
/// On-box proof of the full Linux feedback surface, playing the GAME's role against a real
|
||||
/// kernel: chain A drives rumble through evdev force feedback (`hid-playstation`'s ff-memless
|
||||
/// → UHID_OUTPUT — what SDL/Steam fall back to without hidraw); chain B writes a raw DS5
|
||||
/// output report to the pad's hidraw node (SDL/Steam's real path, and the ONLY way adaptive
|
||||
/// triggers can arrive) and expects rumble + lightbar + player LEDs + both trigger blocks
|
||||
/// back verbatim. Also pins the per-pad pairing MAC: two pads must present distinct uniqs or
|
||||
/// SDL/Steam dedup them into one controller.
|
||||
#[test]
|
||||
#[ignore = "creates real /dev/uhid devices; needs hid-playstation, the input group, and the 60-punktfunk.rules hidraw rules"]
|
||||
fn feedback_flows_via_evdev_ff_and_hidraw() {
|
||||
let mut pad0 = DualSensePad::open(0, &DsUhidIdentity::dualsense()).expect("open pad 0");
|
||||
let mut pad1 = DualSensePad::open(1, &DsUhidIdentity::dualsense()).expect("open pad 1");
|
||||
let st = DsState::neutral();
|
||||
// Let hid-playstation complete its GET_REPORT handshakes and register input devices.
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1500) {
|
||||
let _ = pad0.service(0);
|
||||
let _ = pad1.service(1);
|
||||
let _ = pad0.write_state(&st);
|
||||
let _ = pad1.write_state(&st);
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
let nodes = find_nodes("Punktfunk DualSense 0");
|
||||
assert!(
|
||||
!nodes.is_empty(),
|
||||
"hid-playstation did not bind the uhid device"
|
||||
);
|
||||
let ff_node = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n.as_str())
|
||||
.find(|n| has_ff(n))
|
||||
.expect("no FF-capable evdev among the pad's input devices");
|
||||
|
||||
// Per-pad MAC: hid-playstation adopts the pairing-report MAC as HID_UNIQ; the two pads
|
||||
// must differ (the SDL/Steam serial-dedup regression, see `ds_pairing_reply`).
|
||||
let hidraws = hidraw_devices();
|
||||
let uniq = |name: &str| {
|
||||
hidraws
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == name)
|
||||
.map(|(_, u, _)| u.clone())
|
||||
.unwrap_or_else(|| panic!("no hidraw for {name} in {hidraws:?}"))
|
||||
};
|
||||
assert_ne!(
|
||||
uniq("Punktfunk DualSense 0"),
|
||||
uniq("Punktfunk DualSense 1"),
|
||||
"pads share one pairing MAC — SDL/Steam will dedup them into one controller"
|
||||
);
|
||||
|
||||
// ---- Chain A: evdev force feedback ----
|
||||
let (ff_fd, _) = evdev_rumble(ff_node, 0xC000, 0x4000).expect("EVIOCSFF/play");
|
||||
let (levels, _) = collect(&mut pad0, &st, 1000);
|
||||
assert!(
|
||||
levels.iter().any(|&(l, h)| l > 0 || h > 0),
|
||||
"evdev FF rumble never surfaced as UHID_OUTPUT: {levels:?}"
|
||||
);
|
||||
drop(ff_fd); // closing erases the effect: the stop must surface too
|
||||
let (levels, _) = collect(&mut pad0, &st, 800);
|
||||
assert!(
|
||||
levels.contains(&(0, 0)),
|
||||
"erase-on-close never produced a rumble stop: {levels:?}"
|
||||
);
|
||||
|
||||
// ---- Chain B: raw DS5 output report over hidraw ----
|
||||
let hr = hidraws
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == "Punktfunk DualSense 0")
|
||||
.map(|(_, _, d)| d.clone())
|
||||
.unwrap();
|
||||
let mut rep = [0u8; 48];
|
||||
rep[0] = 0x02; // USB output report id
|
||||
rep[1] = 0x03 | 0x04 | 0x08; // flag0: compat vibration + haptics select + R2 + L2
|
||||
rep[2] = 0x04 | 0x10; // flag1: lightbar + player LEDs
|
||||
rep[3] = 0x60; // motor right (high)
|
||||
rep[4] = 0xA0; // motor left (low)
|
||||
rep[11] = 0x21; // R2 trigger block: weapon mode + params
|
||||
rep[12] = 0x04;
|
||||
rep[13] = 0x07;
|
||||
rep[22] = 0x26; // L2 trigger block: vibration mode + params
|
||||
rep[23] = 0x02;
|
||||
rep[44] = 0x04; // player LED middle
|
||||
rep[45] = 0x10;
|
||||
rep[46] = 0x20;
|
||||
rep[47] = 0x30;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&hr)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, &rep))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"cannot write {hr} as this user ({e}) — Steam/SDL would be equally blocked; \
|
||||
are the 60-punktfunk.rules hidraw rules installed?"
|
||||
)
|
||||
});
|
||||
let (levels, hidout) = collect(&mut pad0, &st, 1000);
|
||||
assert!(
|
||||
levels.contains(&(0xA000, 0x6000)),
|
||||
"hidraw rumble did not surface: {levels:?}"
|
||||
);
|
||||
let triggers: Vec<_> = hidout
|
||||
.iter()
|
||||
.filter_map(|h| match h {
|
||||
HidOutput::Trigger { which, effect, .. } => Some((*which, effect.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
triggers.len(),
|
||||
2,
|
||||
"expected both trigger blocks: {hidout:?}"
|
||||
);
|
||||
assert!(
|
||||
triggers.contains(&(1, rep[11..22].to_vec())),
|
||||
"R2 block not verbatim"
|
||||
);
|
||||
assert!(
|
||||
triggers.contains(&(0, rep[22..33].to_vec())),
|
||||
"L2 block not verbatim"
|
||||
);
|
||||
assert!(
|
||||
hidout.iter().any(|h| matches!(
|
||||
h,
|
||||
HidOutput::Led {
|
||||
r: 0x10,
|
||||
g: 0x20,
|
||||
b: 0x30,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"lightbar not surfaced: {hidout:?}"
|
||||
);
|
||||
assert!(
|
||||
hidout
|
||||
.iter()
|
||||
.any(|h| matches!(h, HidOutput::PlayerLeds { bits: 0x04, .. })),
|
||||
"player LEDs not surfaced: {hidout:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,22 @@
|
||||
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
|
||||
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
|
||||
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
|
||||
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the
|
||||
//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad
|
||||
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout
|
||||
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the
|
||||
//! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure
|
||||
//! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the
|
||||
//! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs.
|
||||
|
||||
use super::dualsense_proto::{DsState, Touch};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
@@ -31,6 +33,8 @@ 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; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
@@ -44,6 +48,17 @@ const BUS_USB: u16 = 0x03;
|
||||
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
|
||||
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// The pairing reply for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low octet offset
|
||||
/// by the pad index — same per-pad-serial contract as the DualSense's
|
||||
/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as
|
||||
/// the HID uniq, and SDL/Steam dedup controllers by that serial.
|
||||
fn ds4_pairing_reply(pad: u8) -> [u8; 16] {
|
||||
let mut r = [0u8; 16];
|
||||
r.copy_from_slice(DS4_FEATURE_PAIRING);
|
||||
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
|
||||
r
|
||||
}
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
||||
0x02,
|
||||
@@ -129,96 +144,6 @@ const DS4_RDESC: &[u8] = &[
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||
const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2)
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||
/// than the DualSense's 1920×1080.
|
||||
pub const DS4_TOUCH_W: u16 = 1920;
|
||||
pub const DS4_TOUCH_H: u16 = 942;
|
||||
|
||||
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
|
||||
/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch;
|
||||
/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp;
|
||||
/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common`
|
||||
/// field at struct offset N sits at report byte N+1.
|
||||
fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||
r[0] = 0x01; // report id
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
// r[12] temperature stays 0
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||
}
|
||||
// r[25..30] reserved2.
|
||||
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||
r[30] = 0x10 | 0x0B;
|
||||
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||
}
|
||||
|
||||
/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble
|
||||
/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player
|
||||
/// LEDs or adaptive triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
pub led: Option<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||
/// doesn't masquerade as a lightbar change.
|
||||
fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||
}
|
||||
let flag0 = data[1];
|
||||
if flag0 & 0x01 != 0 {
|
||||
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||
let low = (data[5] as u16) << 8;
|
||||
let high = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
if flag0 & 0x02 != 0 {
|
||||
fb.led = Some((data[6], data[7], data[8]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
@@ -265,8 +190,8 @@ impl DualShock4Pad {
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DS4_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(&DS4_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes());
|
||||
ev[264..268].copy_from_slice(&(DS4_VENDOR as u32).to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&(DS4_PRODUCT as u32).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 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
|
||||
@@ -292,9 +217,9 @@ impl DualShock4Pad {
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
|
||||
/// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no
|
||||
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake
|
||||
/// completes.
|
||||
pub fn service(&mut self) -> Ds4Feedback {
|
||||
/// [`Ds4Feedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
|
||||
/// init handshake completes.
|
||||
pub fn service(&mut self, pad: u8) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
@@ -311,15 +236,22 @@ impl DualShock4Pad {
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let pairing = ds4_pairing_reply(pad);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x12 => DS4_FEATURE_PAIRING,
|
||||
0x12 => &pairing,
|
||||
0x02 => DS4_FEATURE_CALIBRATION,
|
||||
0xA3 => DS4_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
UHID_SET_REPORT => {
|
||||
// Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s
|
||||
// timeout; DS4 feedback arrives as OUTPUT reports (handled above).
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
@@ -339,6 +271,18 @@ impl DualShock4Pad {
|
||||
.context("write 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());
|
||||
// uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10].
|
||||
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("write UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualShock4Pad {
|
||||
@@ -349,306 +293,109 @@ impl Drop for DualShock4Pad {
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever
|
||||
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich))
|
||||
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back:
|
||||
/// motor rumble on the universal plane, the lightbar on the HID-output plane.
|
||||
pub struct DualShock4Manager {
|
||||
pads: Vec<Option<DualShock4Pad>>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
|
||||
state: Vec<DsState>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
|
||||
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
|
||||
last_led: Vec<Option<(u8, u8, u8)>>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// The DualShock-4-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
||||
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Lifecycle (slot table,
|
||||
/// unplug sweep, heartbeat, dedup) lives in [`UhidManager`]; the lightbar dedup that used to be a
|
||||
/// bespoke `last_led` vec (the kernel bundles the lightbar into every output report, incl.
|
||||
/// rumble-only writes) now rides the shared `HidoutDedup` — identical semantics, `Led` compared
|
||||
/// against the last-forwarded value and re-armed on create/unplug.
|
||||
pub struct Ds4LinuxProto {
|
||||
/// 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 {
|
||||
fn default() -> DualShock4Manager {
|
||||
DualShock4Manager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualShock4Manager {
|
||||
pub fn new() -> DualShock4Manager {
|
||||
DualShock4Manager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
gate: PadGate::new(),
|
||||
impl Default for Ds4LinuxProto {
|
||||
fn default() -> Ds4LinuxProto {
|
||||
Ds4LinuxProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualShock 4)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
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 (DualShock 4)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_led[i] = None;
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
impl PadProto for Ds4LinuxProto {
|
||||
type Pad = DualShock4Pad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualShock 4";
|
||||
const DEVICE: &'static str = "DualShock 4";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualShock4Pad> {
|
||||
let p = DualShock4Pad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad; 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, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DS4 touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1).
|
||||
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
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);
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
let _ = pad.write_state(&st);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||
/// arrive on the rich-input plane and must survive a button-only frame).
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams
|
||||
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a
|
||||
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame);
|
||||
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match DualShock4Pad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_led[idx] = None;
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
|
||||
/// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane);
|
||||
/// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks
|
||||
/// `hid-playstation` init until its GET_REPORTs are answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service();
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = fb.led {
|
||||
if self.last_led[i] != Some(rgb) {
|
||||
self.last_led[i] = Some(rgb);
|
||||
hidout(HidOutput::Led {
|
||||
pad: i as u8,
|
||||
r: rgb.0,
|
||||
g: rgb.1,
|
||||
b: rgb.2,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
||||
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
||||
/// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
|
||||
/// triggers).
|
||||
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||
/// Like the DualSense, the shared [`UhidManager`] keeps each pad's full [`DsState`], re-emits the
|
||||
/// merged report whenever buttons/sticks or touchpad/motion change, and heartbeats it through
|
||||
/// input silence (a real DS4 streams report `0x01` continuously — `hid-playstation`/SDL treat a
|
||||
/// multi-second gap as an unplug).
|
||||
pub type DualShock4Manager = UhidManager<Ds4LinuxProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||
#[test]
|
||||
fn serialize_offsets() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut st = DsState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||
16384, // lx (right)
|
||||
0,
|
||||
0,
|
||||
-32768, // ry (down) — inverted to 0xFF
|
||||
200, // L2
|
||||
0,
|
||||
);
|
||||
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||
st.accel = [0x1112, 0x1314, 0x1516];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
};
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[0], 0x01); // report id
|
||||
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||
assert_eq!(r[33], 1); // one touch frame
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||
#[test]
|
||||
fn parse_output_rumble_and_lightbar() {
|
||||
let mut report = [0u8; 32];
|
||||
report[0] = 0x05;
|
||||
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||
report[4] = 0x40; // motor_right (weak/high)
|
||||
report[5] = 0x80; // motor_left (strong/low)
|
||||
report[6] = 0x11; // R
|
||||
report[7] = 0x22; // G
|
||||
report[8] = 0x33; // B
|
||||
let mut fb = Ds4Feedback::default();
|
||||
parse_ds4_output(&report, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||
|
||||
let mut motor_only = [0u8; 32];
|
||||
motor_only[0] = 0x05;
|
||||
motor_only[1] = 0x01; // MOTOR only
|
||||
motor_only[5] = 0x10;
|
||||
let mut fb2 = Ds4Feedback::default();
|
||||
parse_ds4_output(&motor_only, &mut fb2);
|
||||
assert!(fb2.rumble.is_some());
|
||||
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||
}
|
||||
// The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec
|
||||
// is shared with the Windows backend); only the UHID-transport-specific pieces are tested here.
|
||||
|
||||
/// Feature-report arrays carry the right report id + length the kernel expects.
|
||||
#[test]
|
||||
@@ -660,4 +407,16 @@ mod tests {
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
|
||||
}
|
||||
|
||||
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
|
||||
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
|
||||
#[test]
|
||||
fn pairing_reply_mac_is_per_pad() {
|
||||
assert_eq!(ds4_pairing_reply(0).as_slice(), DS4_FEATURE_PAIRING);
|
||||
let (a, b) = (ds4_pairing_reply(1), ds4_pairing_reply(2));
|
||||
assert_eq!(a[0], 0x12); // report id untouched
|
||||
assert_eq!(a[1], DS4_FEATURE_PAIRING[1].wrapping_add(1));
|
||||
assert_eq!(b[1], DS4_FEATURE_PAIRING[1].wrapping_add(2));
|
||||
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::pad_slots::PadSlots;
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
@@ -551,16 +551,20 @@ impl Drop for VirtualPad {
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual pads of a session, driven from decoded controller events.
|
||||
#[derive(Default)]
|
||||
/// All virtual pads of a session, driven from decoded controller events. Stateless per frame
|
||||
/// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state
|
||||
/// vec, heartbeat, or rich plane like the UHID managers.
|
||||
pub struct GamepadManager {
|
||||
pads: Vec<Option<VirtualPad>>,
|
||||
slots: PadSlots<VirtualPad>,
|
||||
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
|
||||
/// the client asked for `XboxOne`). All pads in a session share one identity.
|
||||
identity: PadIdentity,
|
||||
/// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
fn default() -> GamepadManager {
|
||||
GamepadManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
@@ -572,9 +576,8 @@ impl GamepadManager {
|
||||
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
|
||||
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
|
||||
GamepadManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
slots: PadSlots::new(identity.log, "gamepad", ""),
|
||||
identity,
|
||||
gate: PadGate::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +586,7 @@ impl GamepadManager {
|
||||
use crate::gamestream::gamepad::GamepadEvent;
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival");
|
||||
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
@@ -591,18 +594,14 @@ impl GamepadManager {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
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");
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
|
||||
// state to reset — the pads mix rumble internally).
|
||||
self.slots.sweep(f.active_mask);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
pad.apply(f);
|
||||
}
|
||||
}
|
||||
@@ -610,30 +609,121 @@ impl GamepadManager {
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match VirtualPad::create(idx, self.identity) {
|
||||
Ok(p) => {
|
||||
self.pads[idx] = Some(p);
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
let identity = self.identity;
|
||||
// `VirtualPad::create` logs its own success line (it knows the identity + transport).
|
||||
self.slots
|
||||
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
|
||||
}
|
||||
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if let Some(pad) = slot {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
}
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The FF-capable evdev node whose input-device name contains `name`.
|
||||
fn find_ff_node(name: &str) -> Option<String> {
|
||||
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
let mut cur = String::new();
|
||||
let mut node = None;
|
||||
for line in s.lines() {
|
||||
if let Some(n) = line.strip_prefix("N: Name=") {
|
||||
cur = n.trim_matches('"').to_string();
|
||||
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
||||
if cur.contains(name) {
|
||||
node = h
|
||||
.split_whitespace()
|
||||
.find(|t| t.starts_with("event"))
|
||||
.map(|ev| format!("/dev/input/{ev}"));
|
||||
}
|
||||
} else if line.starts_with("B: FF=")
|
||||
&& cur.contains(name)
|
||||
&& node.is_some()
|
||||
&& !line.trim_end().ends_with("FF=0")
|
||||
{
|
||||
return node;
|
||||
}
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Upload + play an FF_RUMBLE like SDL's evdev haptic backend. Returns the OPEN fd (closing
|
||||
/// it erases the process's effects, stopping the rumble) with the kernel-assigned id.
|
||||
/// NOTE: EVIOCSFF BLOCKS until the uinput owner answers UI_FF_UPLOAD — the caller must be a
|
||||
/// separate thread from the one running [`VirtualPad::pump_ff`], exactly like a real game vs
|
||||
/// the host input loop.
|
||||
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(node)?;
|
||||
let mut eff = [0u8; 48]; // struct ff_effect; union (rumble magnitudes) at offset 16
|
||||
eff[0..2].copy_from_slice(&FF_RUMBLE.to_ne_bytes());
|
||||
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
||||
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
||||
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
||||
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
||||
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
||||
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
||||
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
||||
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
||||
if rc < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
||||
let mut ev = [0u8; 24]; // struct input_event: timeval 16, type u16, code u16, value s32
|
||||
ev[16..18].copy_from_slice(&EV_FF.to_ne_bytes());
|
||||
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
||||
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
||||
f.write_all(&ev)?;
|
||||
Ok((f, id))
|
||||
}
|
||||
|
||||
/// On-box proof of the uinput FF back-channel, playing the GAME's role: an evdev FF_RUMBLE
|
||||
/// upload+play against the virtual X-Box 360 pad must surface through `pump_ff` (the
|
||||
/// EV_UINPUT UI_FF_UPLOAD protocol) — the path every `auto`-kind session's rumble rides on
|
||||
/// Linux — and erasing the effect (fd close) must surface the stop.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uinput device; needs the input group"]
|
||||
fn ff_upload_reaches_pump_and_stops_on_erase() {
|
||||
let mut pad = VirtualPad::create(0, PadIdentity::xbox360()).expect("create uinput pad");
|
||||
std::thread::sleep(Duration::from_millis(700)); // let udev settle the node
|
||||
let node = find_ff_node("Microsoft X-Box 360 pad").expect("no X-Box 360 evdev node");
|
||||
let game = std::thread::spawn(move || {
|
||||
let r = evdev_rumble(&node, 0xC000, 0x4000);
|
||||
std::thread::sleep(Duration::from_millis(1200)); // hold the effect, then erase
|
||||
r.expect("EVIOCSFF/play (fd held meanwhile)");
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut seen = Vec::new();
|
||||
while start.elapsed() < Duration::from_millis(2500) {
|
||||
if let Some(mix) = pad.pump_ff() {
|
||||
seen.push(mix);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
game.join().unwrap();
|
||||
// Requested magnitudes scaled by the 0xFFFF default gain (>> 16).
|
||||
assert!(
|
||||
seen.contains(&(0xBFFF, 0x3FFF)),
|
||||
"evdev FF rumble never surfaced through pump_ff: {seen:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
seen.last(),
|
||||
Some(&(0, 0)),
|
||||
"erase-on-close never produced a stop mix: {seen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,12 @@
|
||||
//! 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,
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
@@ -78,10 +77,12 @@ fn try_clear_lizard_mode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
|
||||
/// down the bound `hid-steam` interface + both evdevs).
|
||||
/// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two
|
||||
/// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-steam` interface + its evdevs).
|
||||
pub struct SteamDeckPad {
|
||||
fd: File,
|
||||
model: SteamModel,
|
||||
seq: u32,
|
||||
created: Instant,
|
||||
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
|
||||
@@ -90,7 +91,16 @@ pub struct SteamDeckPad {
|
||||
|
||||
impl SteamDeckPad {
|
||||
pub fn open(index: u8) -> Result<SteamDeckPad> {
|
||||
try_clear_lizard_mode();
|
||||
SteamDeckPad::open_model(index, SteamModel::Deck)
|
||||
}
|
||||
|
||||
/// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path
|
||||
/// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under
|
||||
/// lizard mode), so the SC skips the whole mode-entry machinery.
|
||||
pub fn open_model(index: u8, model: SteamModel) -> Result<SteamDeckPad> {
|
||||
if model == SteamModel::Deck {
|
||||
try_clear_lizard_mode();
|
||||
}
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
@@ -101,24 +111,29 @@ impl SteamDeckPad {
|
||||
})?;
|
||||
let mut pad = SteamDeckPad {
|
||||
fd,
|
||||
model,
|
||||
seq: 0,
|
||||
created: Instant::now(),
|
||||
menu_hold_since: None,
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?;
|
||||
pad.send_create2(index).context("UHID_CREATE2 Steam pad")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let (name, phys, uniq) = match self.model {
|
||||
SteamModel::Deck => ("Steam Deck", "steam", "steam"),
|
||||
SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"),
|
||||
};
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
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]
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[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[268..272].copy_from_slice(&self.model.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);
|
||||
@@ -126,13 +141,19 @@ impl SteamDeckPad {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it.
|
||||
/// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay +
|
||||
/// anti-toggle guard applied) and write it.
|
||||
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
|
||||
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);
|
||||
match self.model {
|
||||
SteamModel::Deck => {
|
||||
let mut s = *st;
|
||||
s.buttons = self.effective_buttons(st.buttons);
|
||||
serialize_deck_state(&mut r, &s, self.seq);
|
||||
}
|
||||
SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq),
|
||||
}
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
@@ -143,8 +164,9 @@ impl SteamDeckPad {
|
||||
}
|
||||
|
||||
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
|
||||
/// Deck-only — the SC's kernel parser has no mode gate.
|
||||
fn in_mode_entry(&self) -> bool {
|
||||
self.created.elapsed() < MODE_ENTER
|
||||
self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER
|
||||
}
|
||||
|
||||
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
|
||||
@@ -235,16 +257,12 @@ impl Drop for SteamDeckPad {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Input *does* promote. Selected per-pad by [`open_transport`]. (`pub`: the type appears as
|
||||
/// `type Pad` in the `PadProto` impl, a public trait.)
|
||||
pub enum DeckTransport {
|
||||
Uhid(SteamDeckPad),
|
||||
Gadget(crate::inject::steam_gadget::SteamDeckGadget),
|
||||
Usbip(crate::inject::steam_usbip::SteamDeckUsbip),
|
||||
@@ -356,161 +374,200 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
Ok(DeckTransport::Uhid(p))
|
||||
}
|
||||
|
||||
pub struct SteamControllerManager {
|
||||
pads: Vec<Option<DeckTransport>>,
|
||||
state: Vec<SteamState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_write: Vec<Instant>,
|
||||
/// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
}
|
||||
/// The Steam-Deck-specific half of the shared stateful manager (see [`PadProto`]): the transport
|
||||
/// open (usbip → gadget → UHID fallback via [`open_transport`], which logs its own per-transport
|
||||
/// outcome), the [`SteamState`] mappers, and the kernel-handshake service pass. Lifecycle (slot
|
||||
/// table, unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`]; the gamepad-mode-entry
|
||||
/// pulse rides the [`force_heartbeat`](PadProto::force_heartbeat) hook.
|
||||
#[derive(Default)]
|
||||
pub struct SteamProto;
|
||||
|
||||
impl Default for SteamControllerManager {
|
||||
fn default() -> SteamControllerManager {
|
||||
SteamControllerManager::new()
|
||||
impl PadProto for SteamProto {
|
||||
type Pad = DeckTransport;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Deck";
|
||||
const DEVICE: &'static str = "Steam Deck";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DeckTransport> {
|
||||
open_transport(idx)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion arrive
|
||||
/// separately and must survive a button-only frame).
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||
) -> SteamState {
|
||||
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);
|
||||
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
|
||||
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
|
||||
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
|
||||
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
|
||||
s.lpad_click = prev.lpad_click;
|
||||
s.rpad_click = prev.rpad_click;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||
st.apply_rich(rich);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel handshake and forward rumble on the universal plane. The Steam Deck has
|
||||
/// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays
|
||||
/// empty.
|
||||
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn force_heartbeat(&self, pad: &DeckTransport) -> bool {
|
||||
pad.in_mode_entry()
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
gate: PadGate::new(),
|
||||
}
|
||||
}
|
||||
/// 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`; the trackpads + motion
|
||||
/// via `apply_rich`; `pump` services the kernel handshake + routes rumble back; `heartbeat` keeps
|
||||
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
|
||||
pub type SteamControllerManager = UhidManager<SteamProto>;
|
||||
|
||||
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);
|
||||
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
|
||||
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
|
||||
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
|
||||
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
|
||||
s.lpad_click = prev.lpad_click;
|
||||
s.rpad_click = prev.rpad_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam`
|
||||
/// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 —
|
||||
/// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's
|
||||
/// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same
|
||||
/// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware).
|
||||
///
|
||||
/// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads +
|
||||
/// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left
|
||||
/// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel
|
||||
/// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty).
|
||||
pub struct ScProto {
|
||||
/// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4).
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
/// 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.gate.allow(Instant::now()) {
|
||||
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();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
impl Default for ScProto {
|
||||
fn default() -> ScProto {
|
||||
ScProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for ScProto {
|
||||
type Pad = SteamDeckPad;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Controller";
|
||||
const DEVICE: &'static str = "Steam Controller";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<SteamDeckPad> {
|
||||
let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Controller created (UHID hid-steam)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to
|
||||
/// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask
|
||||
/// the native pair out of the fold input so the policy can't double-fire them.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||
) -> SteamState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
|
||||
let folded = crate::inject::steam_remap::fold_paddles(
|
||||
f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2),
|
||||
self.remap.paddles,
|
||||
);
|
||||
let mut s = sc_from_gamepad(
|
||||
folded | native,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.lpad_x = prev.lpad_x;
|
||||
s.lpad_y = prev.lpad_y;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.buttons |= prev.buttons & btn::LPAD_TOUCH;
|
||||
s.lpad_click = prev.lpad_click;
|
||||
// The right pad carries the wire right stick each frame; a rich right-pad contact
|
||||
// (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is
|
||||
// the primary camera surface on this mapping.
|
||||
if f.rs_x == 0 && f.rs_y == 0 {
|
||||
s.rpad_x = prev.rpad_x;
|
||||
s.rpad_y = prev.rpad_y;
|
||||
s.buttons |= prev.buttons & btn::RPAD_TOUCH;
|
||||
s.rpad_click = prev.rpad_click;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||
st.apply_rich(rich);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel
|
||||
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a
|
||||
/// hidraw client (`0xEB`) — surfaced if it ever does.
|
||||
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or
|
||||
/// the per-pad kind a client declares for a physical SC.
|
||||
pub type SteamCtrlManager = UhidManager<ScProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -620,4 +677,40 @@ mod tests {
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows
|
||||
/// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick
|
||||
/// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands
|
||||
/// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap).
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||
fn sc_backend_binds_and_input_flows() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
const BTN_A: u16 = 0x130;
|
||||
const ABS_RX: u16 = 0x03;
|
||||
let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller)
|
||||
.expect("open SC pad (/dev/uhid + input group?)");
|
||||
let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(900) {
|
||||
let _ = pad.service();
|
||||
pad.write_state(&st).expect("write_state");
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
devs.contains("Steam Controller"),
|
||||
"SC gamepad evdev not created"
|
||||
);
|
||||
let node = find_node("Steam Controller").expect("SC evdev node");
|
||||
assert!(
|
||||
key_is_down(&node, BTN_A),
|
||||
"BTN_A not down — SC serialize failed (no mode gate should apply)"
|
||||
);
|
||||
assert_eq!(
|
||||
abs_value(&node, ABS_RX),
|
||||
Some(9000),
|
||||
"wire right stick did not land on the right pad (ABS_RX)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,4 +730,74 @@ mod tests {
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// On-box smoke test (needs root + `vhci_hcd`): rumble the attached virtual Deck exactly like
|
||||
/// Steam does — a `0xEB` feature SET_REPORT on the hid-steam hidraw node — and confirm
|
||||
/// [`SteamDeckUsbip::service`] surfaces `(left, right)` for the 0xCA plane. The Deck presents
|
||||
/// 3 interfaces (0 mouse / 1 kbd / 2 controller); only the CONTROLLER interface's EP0 handler
|
||||
/// parses feedback (the idle interfaces ACK silently, like real hardware), and Steam filters
|
||||
/// on interface 2 — so the write must land there. `#[ignore]`d in CI.
|
||||
#[test]
|
||||
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
|
||||
fn usbip_deck_rumble_flows_via_controller_interface() {
|
||||
use super::super::steam_proto::ID_TRIGGER_RUMBLE_CMD;
|
||||
ensure_modules();
|
||||
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
|
||||
let st = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1500) {
|
||||
pad.write_state(&st);
|
||||
let _ = pad.service();
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
// The hid-steam hidraw node on USB interface 2 (bInterfaceNumber is the HID device's
|
||||
// parent attribute).
|
||||
let node = std::fs::read_dir("/sys/class/hidraw")
|
||||
.expect("/sys/class/hidraw")
|
||||
.flatten()
|
||||
.find_map(|e| {
|
||||
let ue =
|
||||
std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default();
|
||||
let iface = std::fs::read_to_string(e.path().join("device/../bInterfaceNumber"))
|
||||
.ok()
|
||||
.and_then(|s| u8::from_str_radix(s.trim(), 16).ok());
|
||||
(ue.lines().any(|l| l == "DRIVER=hid-steam") && iface == Some(2))
|
||||
.then(|| format!("/dev/{}", e.file_name().to_string_lossy()))
|
||||
})
|
||||
.expect("no hid-steam hidraw on interface 2");
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&node)
|
||||
.expect("open hidraw");
|
||||
// steam_haptic_rumble: [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gain(2)]
|
||||
let mut buf = [0u8; 12];
|
||||
buf[1] = ID_TRIGGER_RUMBLE_CMD;
|
||||
buf[2] = 0x09;
|
||||
buf[6..8].copy_from_slice(&0xC000u16.to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
// HIDIOCSFEATURE(12)
|
||||
let req: libc::c_ulong =
|
||||
(3 << 30) | ((buf.len() as libc::c_ulong) << 16) | (0x48 << 8) | 0x06;
|
||||
// SAFETY: HIDIOCSFEATURE reads the 12-byte report from the live `buf` behind the valid
|
||||
// hidraw fd `f`; the length is encoded in the request, so nothing is written past it.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, buf.as_mut_ptr()) };
|
||||
assert!(
|
||||
rc >= 0,
|
||||
"HIDIOCSFEATURE: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut got = None;
|
||||
while got.is_none() && start.elapsed() < Duration::from_millis(1500) {
|
||||
got = pad.service().rumble;
|
||||
pad.write_state(&st);
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
Some((0xC000, 0x4000)),
|
||||
"Deck rumble never surfaced from the interface-2 SET_REPORT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Virtual Nintendo Switch Pro Controller via UHID — bound by the kernel's `hid-nintendo`
|
||||
//! (≥ 5.16), so a Nintendo-family client pad gets correct glyphs + positional layout, live
|
||||
//! gyro/accel, and HD-rumble feedback, instead of folding to the Xbox 360 pad (mirrored A/B
|
||||
//! + X/Y, no motion).
|
||||
//!
|
||||
//! Unlike `hid-playstation` (whose init is three GET_REPORTs), `hid-nintendo` runs a real
|
||||
//! PROBE CONVERSATION against the device: the `0x80`-family USB commands, then ~a dozen
|
||||
//! subcommands (device info, SPI-flash calibration reads, IMU/vibration enable, input mode,
|
||||
//! player lights) — each a blocking send that must see its reply (input report `0x81`/`0x21`)
|
||||
//! within 1–2 s or probe aborts and NO input devices appear. The whole codec + the canned
|
||||
//! replies live in [`super::switch_proto`]; this module is the `/dev/uhid` plumbing that
|
||||
//! answers them from the [`UhidManager`]'s frequent `service` pass (the same cadence that
|
||||
//! already completes the DualSense handshake).
|
||||
//!
|
||||
//! Post-probe, the driver stalls every LED/rumble write for up to 250 ms unless input reports
|
||||
//! are flowing — the shared manager's 8 ms silence heartbeat provides exactly that steady
|
||||
//! `0x30` stream. On host suspend/resume the driver re-runs the whole init; the service pass
|
||||
//! answers it identically (nothing probe-specific is latched).
|
||||
|
||||
use super::switch_proto::{
|
||||
build_subcmd_reply, build_usb_ack, device_info_payload, parse_output, player_leds_bits,
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
fd: File,
|
||||
index: u8,
|
||||
/// Rolling report timer (byte 1 of every input report).
|
||||
timer: u8,
|
||||
/// The last written state — subcommand replies embed the current input-state header, so the
|
||||
/// probe conversation always reports coherent (neutral, at first) controller state.
|
||||
state: SwitchState,
|
||||
}
|
||||
|
||||
impl SwitchProPad {
|
||||
/// Create the UHID Pro Controller for pad `index` (used for the name/uniq + the virtual MAC).
|
||||
pub fn open(index: u8) -> Result<SwitchProPad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = SwitchProPad {
|
||||
fd,
|
||||
index,
|
||||
timer: 0,
|
||||
state: SwitchState::neutral(),
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Switch Pro")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(
|
||||
&mut ev,
|
||||
4,
|
||||
128,
|
||||
&format!("Punktfunk Switch Pro Controller {index}"),
|
||||
); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus (selects the driver's USB init path)
|
||||
ev[264..268].copy_from_slice(&SWITCH_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&SWITCH_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0200u32.to_ne_bytes()); // version (bcdDevice 2.00)
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + PROCON_RDESC.len()].copy_from_slice(PROCON_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write one full input report to the kernel (UHID_INPUT2).
|
||||
fn write_report(&mut self, r: &[u8; SWITCH_REPORT_LEN]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize the state into the standard `0x30` report and stream it.
|
||||
pub fn write_state(&mut self, st: &SwitchState) -> Result<()> {
|
||||
self.state = *st;
|
||||
self.timer = self.timer.wrapping_add(1);
|
||||
let r = serialize_report_0x30(st, self.timer);
|
||||
self.write_report(&r)
|
||||
}
|
||||
|
||||
/// Answer one subcommand from the driver with its canned `0x21` reply.
|
||||
fn answer_subcmd(&mut self, id: u8, args: &[u8]) {
|
||||
self.timer = self.timer.wrapping_add(1);
|
||||
let st = self.state;
|
||||
let reply = match id {
|
||||
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
||||
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
||||
0x02 => build_subcmd_reply(
|
||||
&st,
|
||||
self.timer,
|
||||
0x82,
|
||||
id,
|
||||
&device_info_payload(&switch_mac(self.index)),
|
||||
),
|
||||
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
|
||||
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
|
||||
// its defaults instead of stalling through 2 × 1 s timeouts.
|
||||
0x10 => {
|
||||
let addr = args
|
||||
.get(..4)
|
||||
.map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]))
|
||||
.unwrap_or(0);
|
||||
let len = args.get(4).copied().unwrap_or(0);
|
||||
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
||||
tracing::debug!(
|
||||
addr = format!("{addr:#x}"),
|
||||
len,
|
||||
"unmapped SPI read — zero fill"
|
||||
);
|
||||
let mut p = Vec::with_capacity(5 + len as usize);
|
||||
p.extend_from_slice(&addr.to_le_bytes());
|
||||
p.push(len);
|
||||
p.extend(std::iter::repeat_n(0u8, len as usize));
|
||||
p
|
||||
});
|
||||
build_subcmd_reply(&st, self.timer, 0x90, id, &payload)
|
||||
}
|
||||
// Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48,
|
||||
// player lights 0x30, home light 0x38, …) just needs the ack + echoed id.
|
||||
_ => build_subcmd_reply(&st, self.timer, 0x80, id, &[]),
|
||||
};
|
||||
let _ = self.write_report(&reply);
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the driver's probe conversation (USB commands +
|
||||
/// subcommands) and surface a game's rumble / player-lights feedback for pad `pad`. Call
|
||||
/// frequently — each probe step blocks the driver until answered.
|
||||
pub fn service(&mut self, pad: u8) -> PadFeedback {
|
||||
let mut fb = PadFeedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
match parse_output(&ev[4..end]) {
|
||||
Some(SwitchOutput::UsbCmd(cmd)) => {
|
||||
// Ack every 0x80 command, incl. no-timeout (0x04) — the driver
|
||||
// ignores that ack but replying skips its 2 × 100 ms wait.
|
||||
let _ = self.write_report(&build_usb_ack(cmd));
|
||||
}
|
||||
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||
fb.rumble = Some(rumble);
|
||||
if id == 0x30 {
|
||||
// Player lights ride the subcommand itself; still ack it.
|
||||
if let Some(&arg) = args.first() {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: player_leds_bits(arg),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.answer_subcmd(id, &args);
|
||||
}
|
||||
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// hid-nintendo never GET_REPORTs; answer EIO so nothing ever blocks on us.
|
||||
let req_id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_get_report_err(req_id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report_err(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&5u16.to_ne_bytes()); // EIO
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwitchProPad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Switch-Pro-specific half of the shared stateful manager (see [`PadProto`]): UHID
|
||||
/// transport open, the [`SwitchState`] mappers, and the probe-conversation service pass.
|
||||
/// Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||
pub struct SwitchProProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (a Pro Controller has no
|
||||
/// back-button slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for SwitchProProto {
|
||||
fn default() -> SwitchProProto {
|
||||
SwitchProProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for SwitchProProto {
|
||||
type Pad = SwitchProPad;
|
||||
type State = SwitchState;
|
||||
const LABEL: &'static str = "Switch Pro";
|
||||
const DEVICE: &'static str = "Switch Pro Controller";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<SwitchProPad> {
|
||||
let p = SwitchProPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Switch Pro Controller created (UHID hid-nintendo)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SwitchState {
|
||||
SwitchState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving motion (it arrives on the rich
|
||||
/// plane and must survive a button-only frame). Paddles fold via the configured policy.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SwitchState,
|
||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||
) -> SwitchState {
|
||||
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = SwitchState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s
|
||||
}
|
||||
|
||||
/// Motion lands on the IMU sample frames; a Pro Controller has no touchpad, so touch events
|
||||
/// are dropped (the client folds trackpads into stick/mouse modes itself).
|
||||
fn apply_rich(&self, st: &mut SwitchState, rich: RichInput) {
|
||||
if let RichInput::Motion { gyro, accel, .. } = rich {
|
||||
st.apply_motion(gyro, accel);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the driver's probe conversation (it blocks `hid-nintendo` init until every step is
|
||||
/// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the
|
||||
/// universal 0xCA plane, player lights on the 0xCD plane.
|
||||
fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback {
|
||||
pad.service(idx)
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Switch Pro Controllers of a session — `PUNKTFUNK_GAMEPAD=switchpro`, or the
|
||||
/// per-pad kind a client declares for a Nintendo-family physical pad.
|
||||
pub type SwitchProManager = UhidManager<SwitchProProto>;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
|
||||
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
|
||||
|
||||
use crate::gamestream::gamepad::MAX_PADS;
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use anyhow::Result;
|
||||
use std::time::Instant;
|
||||
|
||||
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
|
||||
const _: () = assert!(MAX_PADS <= 16);
|
||||
|
||||
/// The slot table + lifecycle every virtual-pad manager repeats: `Vec<Option<P>>` keyed by wire pad
|
||||
/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim
|
||||
/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times.
|
||||
///
|
||||
/// Division of labor: `PadSlots` owns the pads' *existence* (create / sweep / lookup) and logs the
|
||||
/// shared lifecycle lines (unplug, create-failure); the backend keeps everything per-controller —
|
||||
/// its state model, feedback pump, and the success log inside `open` (which knows the transport
|
||||
/// detail worth printing). Per-index sibling state (`state` / `last_rumble` / dedup / clocks) stays
|
||||
/// in the manager, which resets it on the indices [`sweep`](Self::sweep) returns and on a `true`
|
||||
/// from [`ensure`](Self::ensure).
|
||||
pub struct PadSlots<P> {
|
||||
pads: Vec<Option<P>>,
|
||||
/// Create-retry gate: a transient backend failure backs off and retries instead of permanently
|
||||
/// disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"` — keeps every
|
||||
/// existing per-backend line byte-identical (ops greps survive the extraction).
|
||||
label: &'static str,
|
||||
/// Device name in the create-failure line ("virtual `<device>` creation failed …").
|
||||
device: &'static str,
|
||||
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
|
||||
hint: &'static str,
|
||||
}
|
||||
|
||||
impl<P> PadSlots<P> {
|
||||
/// An empty table of [`MAX_PADS`] slots whose lifecycle log lines carry `label` / `device` /
|
||||
/// `hint` (see the field docs).
|
||||
pub fn new(label: &'static str, device: &'static str, hint: &'static str) -> PadSlots<P> {
|
||||
PadSlots {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
gate: PadGate::new(),
|
||||
label,
|
||||
device,
|
||||
hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend tag this table logs with (for the manager's own arrival line).
|
||||
pub fn label(&self) -> &'static str {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose `active_mask` bit cleared (the unplug sweep run on each state
|
||||
/// frame), logging each. Returns the swept indices as a bitmask so the caller resets its
|
||||
/// per-index sibling state; an index another manager owns is `None` here, so it is never swept.
|
||||
pub fn sweep(&mut self, active_mask: u16) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
*slot = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
}
|
||||
swept
|
||||
}
|
||||
|
||||
/// Create the pad at `idx` via `open` if the slot is empty and the create gate allows it.
|
||||
/// Returns `true` only on a fresh create (the caller resets its per-index sibling state);
|
||||
/// `open` logs its own success line (it knows the transport detail), failure is logged here.
|
||||
pub fn ensure(&mut self, idx: usize, open: impl FnOnce(u8) -> Result<P>) -> bool {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return false;
|
||||
}
|
||||
match open(idx as u8) {
|
||||
Ok(p) => {
|
||||
self.pads[idx] = Some(p);
|
||||
self.gate.on_success();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %format!("{e:#}"),
|
||||
"virtual {} creation failed — retrying with backoff{}",
|
||||
self.device,
|
||||
self.hint
|
||||
);
|
||||
self.gate.on_failure(Instant::now());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live pad at `idx`, if any (out-of-range → `None`).
|
||||
pub fn get(&self, idx: usize) -> Option<&P> {
|
||||
self.pads.get(idx).and_then(|s| s.as_ref())
|
||||
}
|
||||
|
||||
/// The live pad at `idx`, mutably, if any (out-of-range → `None`).
|
||||
pub fn get_mut(&mut self, idx: usize) -> Option<&mut P> {
|
||||
self.pads.get_mut(idx).and_then(|s| s.as_mut())
|
||||
}
|
||||
|
||||
/// Iterate the live pads as `(index, &mut pad)` (the feedback-pump shape).
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (usize, &mut P)> {
|
||||
self.pads
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| s.as_mut().map(|p| (i, p)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::bail;
|
||||
|
||||
fn slots() -> PadSlots<u32> {
|
||||
PadSlots::new("Test", "test pad", "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_once_and_reports_freshness() {
|
||||
let mut s = slots();
|
||||
// Fresh create → true; the pad is live.
|
||||
assert!(s.ensure(3, |i| Ok(i as u32 * 10)));
|
||||
assert_eq!(s.get(3), Some(&30));
|
||||
// Occupied slot → no re-open (the closure must not run), no reset signal.
|
||||
assert!(!s.ensure(3, |_| panic!("re-opened an occupied slot")));
|
||||
// Out of range → never opens.
|
||||
assert!(!s.ensure(MAX_PADS, |_| panic!("opened out of range")));
|
||||
assert_eq!(s.get(MAX_PADS), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_drops_only_cleared_bits_and_returns_them_once() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |_| Ok(0)));
|
||||
assert!(s.ensure(2, |_| Ok(2)));
|
||||
assert!(s.ensure(5, |_| Ok(5)));
|
||||
// Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events.
|
||||
let swept = s.sweep(0b0000_0100);
|
||||
assert_eq!(swept, 0b0010_0001);
|
||||
assert_eq!(s.get(0), None);
|
||||
assert_eq!(s.get(2), Some(&2));
|
||||
assert_eq!(s.get(5), None);
|
||||
// A second identical sweep is a no-op: the indices were returned exactly once.
|
||||
assert_eq!(s.sweep(0b0000_0100), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_failure_arms_the_gate_and_success_heals_it() {
|
||||
let mut s = slots();
|
||||
assert!(!s.ensure(1, |_| bail!("transient")));
|
||||
// Backoff in effect: the next attempt is blocked without even calling `open`.
|
||||
assert!(!s.ensure(1, |_| panic!("open during backoff")));
|
||||
// The gate is manager-wide (create failures are systemic), so other indices block too.
|
||||
assert!(!s.ensure(2, |_| panic!("open during backoff")));
|
||||
// …and a sweep-then-recreate of a *different* live pad is equally gated, but the table
|
||||
// itself is intact: nothing was allocated.
|
||||
assert_eq!(s.get(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreate_after_sweep_resets_freshness() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(4, |_| Ok(1)));
|
||||
s.sweep(0);
|
||||
assert_eq!(s.get(4), None);
|
||||
// The slot is free again → a fresh create (true) with a new value.
|
||||
assert!(s.ensure(4, |_| Ok(2)));
|
||||
assert_eq!(s.get(4), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_mut_yields_live_pads_with_indices() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(1, |_| Ok(10)));
|
||||
assert!(s.ensure(6, |_| Ok(60)));
|
||||
let seen: Vec<(usize, u32)> = s.iter_mut().map(|(i, p)| (i, *p)).collect();
|
||||
assert_eq!(seen, vec![(1, 10), (6, 60)]);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,18 @@ pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// The pairing reply (report `0x09`) for wire pad `pad`: [`DS_FEATURE_PAIRING`] with the MAC's low
|
||||
/// octet offset by the pad index. The MAC must be **unique per pad**: `hid-playstation` adopts it
|
||||
/// as the HID `uniq` (replacing whatever uniq the device was created with), and SDL/Steam dedup
|
||||
/// controllers by that serial — with identical MACs a second virtual pad reads as the *first* pad
|
||||
/// re-appearing over another transport and is merged/ignored.
|
||||
pub fn ds_pairing_reply(pad: u8) -> [u8; 20] {
|
||||
let mut r = [0u8; 20];
|
||||
r.copy_from_slice(DS_FEATURE_PAIRING);
|
||||
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
|
||||
r
|
||||
}
|
||||
|
||||
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
|
||||
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
|
||||
#[rustfmt::skip]
|
||||
@@ -66,8 +78,45 @@ pub const DUALSENSE_RDESC: &[u8] = &[
|
||||
0xC0,
|
||||
];
|
||||
|
||||
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
|
||||
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
|
||||
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
|
||||
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
|
||||
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
|
||||
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
|
||||
/// reserved bits of `buttons[2]`, see [`btn2`]).
|
||||
#[rustfmt::skip]
|
||||
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
|
||||
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
|
||||
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
|
||||
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
|
||||
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
|
||||
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
|
||||
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
|
||||
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
|
||||
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
|
||||
0x09, 0x53, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
|
||||
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
pub const DS_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
|
||||
@@ -92,12 +141,47 @@ pub mod btn1 {
|
||||
pub const L3: u8 = 0x40;
|
||||
pub const R3: u8 = 0x80;
|
||||
}
|
||||
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
|
||||
/// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
|
||||
/// back buttons in bits 4–7 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
|
||||
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
|
||||
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
|
||||
pub mod btn2 {
|
||||
pub const PS: u8 = 0x01;
|
||||
pub const TOUCHPAD: u8 = 0x02;
|
||||
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
|
||||
pub const MUTE: u8 = 0x04;
|
||||
/// Edge left Fn button (below the left stick).
|
||||
pub const EDGE_FN_LEFT: u8 = 0x10;
|
||||
/// Edge right Fn button.
|
||||
pub const EDGE_FN_RIGHT: u8 = 0x20;
|
||||
/// Edge left back button (rear paddle).
|
||||
pub const EDGE_BACK_LEFT: u8 = 0x40;
|
||||
/// Edge right back button (rear paddle).
|
||||
pub const EDGE_BACK_RIGHT: u8 = 0x80;
|
||||
}
|
||||
|
||||
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
|
||||
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1–P4) land on native
|
||||
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
|
||||
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
|
||||
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
|
||||
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
|
||||
pub fn edge_paddle_bits(buttons: u32) -> u8 {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut b = 0;
|
||||
if buttons & gs::BTN_PADDLE1 != 0 {
|
||||
b |= btn2::EDGE_BACK_RIGHT; // R4
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE2 != 0 {
|
||||
b |= btn2::EDGE_BACK_LEFT; // L4
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE3 != 0 {
|
||||
b |= btn2::EDGE_FN_RIGHT; // R5
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE4 != 0 {
|
||||
b |= btn2::EDGE_FN_LEFT; // L5
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// One touchpad contact for the report.
|
||||
@@ -798,6 +882,51 @@ mod tests {
|
||||
assert_eq!(s.buttons[2], 0);
|
||||
}
|
||||
|
||||
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
|
||||
/// 4–7 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
|
||||
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
|
||||
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
|
||||
#[test]
|
||||
fn edge_paddles_map_to_native_bits() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
assert_eq!(edge_paddle_bits(0), 0);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
|
||||
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
|
||||
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
|
||||
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
|
||||
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
|
||||
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
|
||||
// All four + a non-paddle bit: paddles map, the rest is ignored here.
|
||||
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
|
||||
assert_eq!(edge_paddle_bits(all), 0xF0);
|
||||
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
|
||||
// the ordinary bits (e.g. a simultaneous PS press).
|
||||
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
|
||||
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &s, 0, 0);
|
||||
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
|
||||
}
|
||||
|
||||
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
|
||||
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
|
||||
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
|
||||
/// the serializer needs no Edge variant).
|
||||
#[test]
|
||||
fn edge_descriptor_shape() {
|
||||
assert_eq!(DUALSENSE_RDESC.len(), 273);
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
|
||||
// Identical through the input-report + output-report-id prefix; the first delta is the
|
||||
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
|
||||
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
@@ -806,4 +935,16 @@ mod tests {
|
||||
assert!(fb.rumble.is_none());
|
||||
assert!(fb.hidout.is_empty());
|
||||
}
|
||||
|
||||
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
|
||||
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
|
||||
#[test]
|
||||
fn pairing_reply_mac_is_per_pad() {
|
||||
assert_eq!(ds_pairing_reply(0).as_slice(), DS_FEATURE_PAIRING);
|
||||
let (a, b) = (ds_pairing_reply(1), ds_pairing_reply(2));
|
||||
assert_eq!(a[0], 0x09); // report id untouched
|
||||
assert_eq!(a[1], DS_FEATURE_PAIRING[1].wrapping_add(1));
|
||||
assert_eq!(b[1], DS_FEATURE_PAIRING[1].wrapping_add(2));
|
||||
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
|
||||
//! UMDF-driver backend ([`super::dualshock4_windows`]).
|
||||
//!
|
||||
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
|
||||
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
|
||||
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
|
||||
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
|
||||
//! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
|
||||
//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
|
||||
//! ([`super::dualshock4`]).
|
||||
//!
|
||||
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
|
||||
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
|
||||
@@ -17,7 +13,6 @@
|
||||
//! dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
|
||||
use super::dualsense_proto::{DsState, Touch};
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
|
||||
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
|
||||
pub const DS4_VENDOR: u16 = 0x054C;
|
||||
@@ -77,11 +72,10 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
|
||||
}
|
||||
|
||||
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
|
||||
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
|
||||
/// triggers, so those never appear).
|
||||
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
|
||||
/// or adaptive triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
@@ -149,6 +143,14 @@ mod tests {
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
|
||||
// A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
|
||||
// touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
|
||||
// serialize raw `buttons[2]` here and drop it.
|
||||
assert_eq!(r[7] & 0x02, 0); // no click yet
|
||||
st.touch_click[0] = true;
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[7] & 0x02, 0x02);
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
|
||||
@@ -341,6 +341,129 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
|
||||
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
|
||||
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
|
||||
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 0–6), so this reuses the [`btn`] masks —
|
||||
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
|
||||
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
|
||||
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
|
||||
/// [`super::steam_remap`] BEFORE calling this.
|
||||
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
|
||||
/// DualSense `BTN_TOUCHPAD` click both land there.
|
||||
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
|
||||
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
|
||||
///
|
||||
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
|
||||
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
|
||||
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
|
||||
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
|
||||
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
|
||||
pub fn sc_from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> SteamState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut s = SteamState {
|
||||
lx,
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
let set = |b: &mut u64, on: bool, m: u64| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), btn::A);
|
||||
set(&mut b, on(gs::BTN_B), btn::B);
|
||||
set(&mut b, on(gs::BTN_X), btn::X);
|
||||
set(&mut b, on(gs::BTN_Y), btn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), btn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), btn::RB);
|
||||
set(&mut b, lt > 0, btn::LT_FULL);
|
||||
set(&mut b, rt > 0, btn::RT_FULL);
|
||||
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), btn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
|
||||
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
||||
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
||||
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
||||
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||
set(
|
||||
&mut b,
|
||||
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
|
||||
btn::RPAD_CLICK,
|
||||
);
|
||||
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
|
||||
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
|
||||
s.buttons = b;
|
||||
s
|
||||
}
|
||||
|
||||
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
|
||||
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
|
||||
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
|
||||
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
|
||||
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
|
||||
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
|
||||
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
|
||||
r.fill(0);
|
||||
r[0] = 0x01;
|
||||
r[1] = 0x00;
|
||||
r[2] = ID_CONTROLLER_STATE;
|
||||
r[3] = 0x3C;
|
||||
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
|
||||
// the kernel maps no key to it), right-pad clicked = 10.2.
|
||||
let mut buttons = st.buttons;
|
||||
if st.lpad_click {
|
||||
buttons |= btn::LPAD_CLICK;
|
||||
}
|
||||
if st.rpad_click {
|
||||
buttons |= btn::RPAD_CLICK;
|
||||
}
|
||||
r[8] = (buttons & 0xFF) as u8;
|
||||
r[9] = ((buttons >> 8) & 0xFF) as u8;
|
||||
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
||||
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
||||
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
||||
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
||||
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
||||
(st.lpad_x, st.lpad_y)
|
||||
} else {
|
||||
(st.lx, st.ly)
|
||||
};
|
||||
r[16..18].copy_from_slice(&x.to_le_bytes());
|
||||
r[18..20].copy_from_slice(&y.to_le_bytes());
|
||||
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
||||
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
|
||||
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
|
||||
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
|
||||
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
|
||||
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
|
||||
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
|
||||
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
|
||||
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// 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`,
|
||||
@@ -419,12 +542,16 @@ 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.
|
||||
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
|
||||
/// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
|
||||
/// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
|
||||
/// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
|
||||
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
|
||||
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
|
||||
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
|
||||
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
|
||||
pub fn deck_serial(index: u8) -> String {
|
||||
format!("PFDK{:08X}", deck_unit_id(index))
|
||||
format!("FVPF{:08X}", deck_unit_id(index))
|
||||
}
|
||||
|
||||
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
||||
@@ -693,6 +820,68 @@ mod tests {
|
||||
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
|
||||
}
|
||||
|
||||
/// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
|
||||
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
|
||||
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
|
||||
/// 10.2, joystick click at 10.6).
|
||||
#[test]
|
||||
fn sc_serialize_and_mapping() {
|
||||
// Full mapping: face + grips + clicks + a deflected right stick.
|
||||
let s = sc_from_gamepad(
|
||||
gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
|
||||
1000,
|
||||
-2000,
|
||||
3000,
|
||||
-4000,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::A, 0);
|
||||
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
|
||||
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
|
||||
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
|
||||
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
|
||||
assert_eq!((s.rx, s.ry), (0, 0));
|
||||
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_sc_state(&mut r, &s, 0x0102_0304);
|
||||
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
|
||||
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
|
||||
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
|
||||
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
|
||||
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
|
||||
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
|
||||
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
|
||||
assert_eq!(r[11], 255); // left trigger u8
|
||||
assert_eq!(r[12], 0); // right trigger u8
|
||||
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
|
||||
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
|
||||
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
|
||||
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
|
||||
|
||||
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
|
||||
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
|
||||
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: -5000,
|
||||
y: 6000,
|
||||
pressure: 0,
|
||||
});
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_sc_state(&mut r, &s, 0);
|
||||
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
|
||||
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
|
||||
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
|
||||
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
|
||||
}
|
||||
|
||||
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
||||
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
||||
#[test]
|
||||
@@ -729,7 +918,7 @@ mod tests {
|
||||
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, "FVPF50460000"); // 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.
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned
|
||||
//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with.
|
||||
//!
|
||||
//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c —
|
||||
//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input
|
||||
//! devices appear):
|
||||
//!
|
||||
//! - **USB handshake**: 2-byte output reports `0x80 <cmd>` (handshake / baudrate / no-timeout),
|
||||
//! each ACKed with an input report `0x81 <cmd>` (`joycon_send_usb` matches only those two
|
||||
//! bytes).
|
||||
//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id +
|
||||
//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte /
|
||||
//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and
|
||||
//! requires ≥ 49 bytes; real hardware sends 64.
|
||||
//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics
|
||||
//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations
|
||||
//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the
|
||||
//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks
|
||||
//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default
|
||||
//! scales (accel 16384, gyro 13371) so raw units pass through 1:1.
|
||||
//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit
|
||||
//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE).
|
||||
//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the
|
||||
//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the
|
||||
//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high).
|
||||
//!
|
||||
//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4):
|
||||
//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch
|
||||
//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise),
|
||||
//! so the physical-position ↔ glyph relationship stays correct end-to-end.
|
||||
//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants
|
||||
//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g
|
||||
//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
|
||||
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
|
||||
|
||||
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
|
||||
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
|
||||
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
|
||||
/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges
|
||||
/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events,
|
||||
/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents.
|
||||
/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set).
|
||||
#[rustfmt::skip]
|
||||
pub const PROCON_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
|
||||
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
|
||||
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
|
||||
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
|
||||
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
|
||||
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
|
||||
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
|
||||
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
|
||||
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
|
||||
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
|
||||
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
|
||||
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
|
||||
];
|
||||
/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`).
|
||||
pub const SWITCH_REPORT_LEN: usize = 64;
|
||||
|
||||
/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis).
|
||||
/// The factory blobs below advertise exactly this, so the driver maps
|
||||
/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values.
|
||||
pub const STICK_CENTER: u16 = 2048;
|
||||
pub const STICK_RANGE: u16 = 1400;
|
||||
|
||||
/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full),
|
||||
/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery
|
||||
/// warnings ever.
|
||||
pub const BAT_CON_FULL_WIRED: u8 = 0x91;
|
||||
/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble
|
||||
/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish.
|
||||
pub const VIBRATOR_READY: u8 = 0x70;
|
||||
|
||||
// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's
|
||||
// JC_BTN_* defines.
|
||||
pub mod btn {
|
||||
pub const Y: u32 = 1 << 0;
|
||||
pub const X: u32 = 1 << 1;
|
||||
pub const B: u32 = 1 << 2;
|
||||
pub const A: u32 = 1 << 3;
|
||||
pub const R: u32 = 1 << 6;
|
||||
pub const ZR: u32 = 1 << 7;
|
||||
pub const MINUS: u32 = 1 << 8;
|
||||
pub const PLUS: u32 = 1 << 9;
|
||||
pub const RSTICK: u32 = 1 << 10;
|
||||
pub const LSTICK: u32 = 1 << 11;
|
||||
pub const HOME: u32 = 1 << 12;
|
||||
pub const CAPTURE: u32 = 1 << 13;
|
||||
pub const DOWN: u32 = 1 << 16;
|
||||
pub const UP: u32 = 1 << 17;
|
||||
pub const RIGHT: u32 = 1 << 18;
|
||||
pub const LEFT: u32 = 1 << 19;
|
||||
pub const L: u32 = 1 << 22;
|
||||
pub const ZL: u32 = 1 << 23;
|
||||
}
|
||||
|
||||
/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers).
|
||||
/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SwitchState {
|
||||
/// 24-bit `JC_BTN_*` field.
|
||||
pub buttons: u32,
|
||||
pub lx: u16,
|
||||
pub ly: u16,
|
||||
pub rx: u16,
|
||||
pub ry: u16,
|
||||
/// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z.
|
||||
pub gyro: [i16; 3],
|
||||
pub accel: [i16; 3],
|
||||
}
|
||||
|
||||
impl SwitchState {
|
||||
/// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so
|
||||
/// SDL/games don't see a free-falling controller).
|
||||
pub fn neutral() -> SwitchState {
|
||||
SwitchState {
|
||||
buttons: 0,
|
||||
lx: STICK_CENTER,
|
||||
ly: STICK_CENTER,
|
||||
rx: STICK_CENTER,
|
||||
ry: STICK_CENTER,
|
||||
gyro: [0; 3],
|
||||
accel: [0, 0, 4096],
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped
|
||||
/// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are
|
||||
/// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no
|
||||
/// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the
|
||||
/// DualSense-family backends do).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> SwitchState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut b = 0u32;
|
||||
// Positional: wire south/east/west/north → the Switch button at that position.
|
||||
if on(gs::BTN_A) {
|
||||
b |= btn::B; // south
|
||||
}
|
||||
if on(gs::BTN_B) {
|
||||
b |= btn::A; // east
|
||||
}
|
||||
if on(gs::BTN_X) {
|
||||
b |= btn::Y; // west
|
||||
}
|
||||
if on(gs::BTN_Y) {
|
||||
b |= btn::X; // north
|
||||
}
|
||||
if on(gs::BTN_LB) {
|
||||
b |= btn::L;
|
||||
}
|
||||
if on(gs::BTN_RB) {
|
||||
b |= btn::R;
|
||||
}
|
||||
if lt > 0 {
|
||||
b |= btn::ZL;
|
||||
}
|
||||
if rt > 0 {
|
||||
b |= btn::ZR;
|
||||
}
|
||||
if on(gs::BTN_BACK) {
|
||||
b |= btn::MINUS;
|
||||
}
|
||||
if on(gs::BTN_START) {
|
||||
b |= btn::PLUS;
|
||||
}
|
||||
if on(gs::BTN_LS_CLICK) {
|
||||
b |= btn::LSTICK;
|
||||
}
|
||||
if on(gs::BTN_RS_CLICK) {
|
||||
b |= btn::RSTICK;
|
||||
}
|
||||
if on(gs::BTN_GUIDE) {
|
||||
b |= btn::HOME;
|
||||
}
|
||||
if on(gs::BTN_MISC1) {
|
||||
b |= btn::CAPTURE;
|
||||
}
|
||||
if on(gs::BTN_DPAD_UP) {
|
||||
b |= btn::UP;
|
||||
}
|
||||
if on(gs::BTN_DPAD_DOWN) {
|
||||
b |= btn::DOWN;
|
||||
}
|
||||
if on(gs::BTN_DPAD_LEFT) {
|
||||
b |= btn::LEFT;
|
||||
}
|
||||
if on(gs::BTN_DPAD_RIGHT) {
|
||||
b |= btn::RIGHT;
|
||||
}
|
||||
SwitchState {
|
||||
buttons: b,
|
||||
lx: stick_raw(lx),
|
||||
ly: stick_raw(ly),
|
||||
rx: stick_raw(rx),
|
||||
ry: stick_raw(ry),
|
||||
..SwitchState::neutral()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
|
||||
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
|
||||
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
|
||||
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
|
||||
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
|
||||
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
|
||||
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the
|
||||
/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's
|
||||
/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x.
|
||||
pub fn stick_raw(v: i16) -> u16 {
|
||||
let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767;
|
||||
raw.clamp(0, 0xFFF) as u16
|
||||
}
|
||||
|
||||
/// Pack two 12-bit values into the 3-byte stick / calibration wire form
|
||||
/// (`hid_field_extract` little-endian bitfield order).
|
||||
pub fn pack12(a: u16, b: u16) -> [u8; 3] {
|
||||
[
|
||||
(a & 0xFF) as u8,
|
||||
((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8,
|
||||
((b >> 4) & 0xFF) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the
|
||||
/// `0x30` stream and every `0x21` subcommand reply carry.
|
||||
fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) {
|
||||
r[0] = id;
|
||||
r[1] = timer;
|
||||
r[2] = BAT_CON_FULL_WIRED;
|
||||
r[3] = (st.buttons & 0xFF) as u8;
|
||||
r[4] = ((st.buttons >> 8) & 0xFF) as u8;
|
||||
r[5] = ((st.buttons >> 16) & 0xFF) as u8;
|
||||
r[6..9].copy_from_slice(&pack12(st.lx, st.ly));
|
||||
r[9..12].copy_from_slice(&pack12(st.rx, st.ry));
|
||||
r[12] = VIBRATOR_READY;
|
||||
}
|
||||
|
||||
/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames
|
||||
/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current
|
||||
/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame).
|
||||
pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
write_header(&mut r, 0x30, st, timer);
|
||||
for frame in 0..3 {
|
||||
let off = 13 + frame * 12;
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Build the `0x81 <cmd>` input report acknowledging a USB `0x80 <cmd>` command
|
||||
/// (`joycon_send_usb` matches exactly those two bytes).
|
||||
pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
r[0] = 0x81;
|
||||
r[1] = cmd;
|
||||
r
|
||||
}
|
||||
|
||||
/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload.
|
||||
/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware
|
||||
/// (`0x80` plain ack, `0x80 | data-type` when a payload follows).
|
||||
pub fn build_subcmd_reply(
|
||||
st: &SwitchState,
|
||||
timer: u8,
|
||||
ack: u8,
|
||||
subcmd: u8,
|
||||
payload: &[u8],
|
||||
) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
write_header(&mut r, 0x21, st, timer);
|
||||
r[13] = ack;
|
||||
r[14] = subcmd;
|
||||
let n = payload.len().min(SWITCH_REPORT_LEN - 15);
|
||||
r[15..15 + n].copy_from_slice(&payload[..n]);
|
||||
r
|
||||
}
|
||||
|
||||
/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller**
|
||||
/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte
|
||||
/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`,
|
||||
/// and `0x01` = "colors in SPI" (not read by the driver).
|
||||
pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] {
|
||||
let mut p = [0u8; 12];
|
||||
p[0] = 0x04;
|
||||
p[1] = 0x21;
|
||||
p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO
|
||||
p[3] = 0x02;
|
||||
p[4..10].copy_from_slice(mac);
|
||||
p[10] = 0x01;
|
||||
p[11] = 0x01;
|
||||
p
|
||||
}
|
||||
|
||||
/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from
|
||||
/// device info and keys the input devices' `uniq` off it.
|
||||
pub fn switch_mac(index: u8) -> [u8; 6] {
|
||||
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
|
||||
}
|
||||
|
||||
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
|
||||
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
|
||||
/// zeroes — the driver falls back to defaults rather than aborting).
|
||||
///
|
||||
/// Served ranges:
|
||||
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
|
||||
/// driver takes the factory path.
|
||||
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
|
||||
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
|
||||
/// min-below ++ max-above (`joycon_read_stick_calibration`).
|
||||
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
|
||||
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
|
||||
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
|
||||
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
|
||||
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
|
||||
let data: Vec<u8> = match (addr, len) {
|
||||
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
|
||||
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
|
||||
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
|
||||
(0x6020, 24) => {
|
||||
let mut v = Vec::with_capacity(24);
|
||||
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
|
||||
}
|
||||
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut payload = Vec::with_capacity(5 + data.len());
|
||||
payload.extend_from_slice(&addr.to_le_bytes());
|
||||
payload.push(len);
|
||||
payload.extend_from_slice(&data);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
/// One decoded host-bound output report from the driver.
|
||||
pub enum SwitchOutput {
|
||||
/// `0x80 <cmd>` USB command — answer with [`build_usb_ack`].
|
||||
UsbCmd(u8),
|
||||
/// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply.
|
||||
Subcmd {
|
||||
id: u8,
|
||||
/// Subcommand argument bytes (report bytes 11..).
|
||||
args: Vec<u8>,
|
||||
/// Decoded rumble `(low, high)` magnitudes.
|
||||
rumble: (u16, u16),
|
||||
},
|
||||
/// `0x10` rumble-only report — no reply expected.
|
||||
Rumble((u16, u16)),
|
||||
}
|
||||
|
||||
/// Parse one output report from the driver. Returns `None` for anything unrecognized/short.
|
||||
pub fn parse_output(data: &[u8]) -> Option<SwitchOutput> {
|
||||
match *data.first()? {
|
||||
0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)),
|
||||
0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd {
|
||||
id: data[10],
|
||||
args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(),
|
||||
rumble: decode_rumble(&data[2..10]),
|
||||
}),
|
||||
0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by
|
||||
/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from
|
||||
/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003).
|
||||
#[rustfmt::skip]
|
||||
const RUMBLE_AMPS: [u16; 101] = [
|
||||
0, 10, 12, 14, 17, 20, 24, 28, 33, 40,
|
||||
47, 56, 67, 80, 95, 112, 117, 123, 128, 134,
|
||||
140, 146, 152, 159, 166, 173, 181, 189, 198, 206,
|
||||
215, 225, 230, 235, 240, 245, 251, 256, 262, 268,
|
||||
273, 279, 286, 292, 298, 305, 311, 318, 325, 332,
|
||||
340, 347, 355, 362, 370, 378, 387, 395, 404, 413,
|
||||
422, 431, 440, 450, 460, 470, 480, 491, 501, 512,
|
||||
524, 535, 547, 559, 571, 584, 596, 609, 623, 636,
|
||||
650, 665, 679, 694, 709, 725, 741, 757, 773, 790,
|
||||
808, 825, 843, 862, 881, 900, 920, 940, 960, 981,
|
||||
1003,
|
||||
];
|
||||
|
||||
/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled
|
||||
/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo +
|
||||
/// amp.high`, where the freq contribution is only ever bit 0).
|
||||
fn side_amplitude(side: &[u8]) -> u16 {
|
||||
let idx = ((side[1] & 0xFE) / 2) as usize;
|
||||
let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32;
|
||||
// Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale.
|
||||
((amp * 65535) / 1003).min(65535) as u16
|
||||
}
|
||||
|
||||
/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire
|
||||
/// `high`, per `joycon_play_effect`).
|
||||
pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) {
|
||||
if bytes.len() < 8 {
|
||||
return (0, 0);
|
||||
}
|
||||
(side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8]))
|
||||
}
|
||||
|
||||
/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the
|
||||
/// wire `PlayerLeds` bits: a flashing LED counts as on.
|
||||
pub fn player_leds_bits(arg: u8) -> u8 {
|
||||
(arg & 0x0F) | (arg >> 4)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits
|
||||
/// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct
|
||||
/// end-to-end), and the rest of the buttons land on their JC_BTN_* bits.
|
||||
#[test]
|
||||
fn positional_swap_and_button_bits() {
|
||||
let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::B);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::A);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::Y);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::X);
|
||||
// Shoulders / sticks / meta / dpad / triggers-as-digital.
|
||||
let st = SwitchState::from_gamepad(
|
||||
gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
st.buttons,
|
||||
btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR
|
||||
);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::UP | btn::LEFT);
|
||||
}
|
||||
|
||||
/// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same
|
||||
/// direction (the driver's own Y negation restores evdev's negative-up).
|
||||
#[test]
|
||||
fn stick_scaling() {
|
||||
assert_eq!(stick_raw(0), STICK_CENTER);
|
||||
assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE);
|
||||
assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE);
|
||||
// Extreme min doesn't underflow past the 12-bit range.
|
||||
assert!(stick_raw(i16::MIN) <= 0xFFF);
|
||||
}
|
||||
|
||||
/// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order:
|
||||
/// value A at bit 0, value B at bit 12.
|
||||
#[test]
|
||||
fn pack12_layout() {
|
||||
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
||||
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
||||
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
||||
let p = pack12(0xABC, 0x123);
|
||||
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
||||
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
||||
assert_eq!((a, b), (0xABC, 0x123));
|
||||
}
|
||||
|
||||
/// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`:
|
||||
/// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE).
|
||||
#[test]
|
||||
fn report_0x30_layout() {
|
||||
let mut st = SwitchState::neutral();
|
||||
st.buttons = btn::B | btn::MINUS | btn::ZL;
|
||||
st.gyro = [0x1122, -2, 3];
|
||||
st.accel = [-1, 0x3344, 5];
|
||||
let r = serialize_report_0x30(&st, 7);
|
||||
assert_eq!(r[0], 0x30);
|
||||
assert_eq!(r[1], 7);
|
||||
assert_eq!(r[2], BAT_CON_FULL_WIRED);
|
||||
assert_eq!(r[3], 0x04); // B = bit 2
|
||||
assert_eq!(r[4], 0x01); // MINUS = bit 8
|
||||
assert_eq!(r[5], 0x80); // ZL = bit 23
|
||||
assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER));
|
||||
assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER));
|
||||
assert_eq!(r[12], VIBRATOR_READY);
|
||||
// Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE.
|
||||
assert_eq!(&r[13..15], &(-1i16).to_le_bytes());
|
||||
assert_eq!(&r[15..17], &0x3344u16.to_le_bytes());
|
||||
assert_eq!(&r[19..21], &0x1122u16.to_le_bytes());
|
||||
// Frames repeat identically at +12 and +24.
|
||||
assert_eq!(&r[13..25], &r[25..37]);
|
||||
assert_eq!(&r[13..25], &r[37..49]);
|
||||
}
|
||||
|
||||
/// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the
|
||||
/// ONLY byte the driver's matcher checks), payload from byte 15.
|
||||
#[test]
|
||||
fn subcmd_reply_layout() {
|
||||
let st = SwitchState::neutral();
|
||||
let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]);
|
||||
assert_eq!(r.len(), SWITCH_REPORT_LEN);
|
||||
assert_eq!(r[0], 0x21);
|
||||
assert_eq!(r[13], 0x90);
|
||||
assert_eq!(r[14], 0x10);
|
||||
assert_eq!(&r[15..17], &[0xAA, 0xBB]);
|
||||
// USB ack: exactly the two bytes joycon_send_usb matches.
|
||||
let a = build_usb_ack(0x02);
|
||||
assert_eq!((a[0], a[1]), (0x81, 0x02));
|
||||
}
|
||||
|
||||
/// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between
|
||||
/// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len.
|
||||
#[test]
|
||||
fn spi_blobs_valid() {
|
||||
for addr in [0x8010u32, 0x801B, 0x8026] {
|
||||
let p = spi_flash_read(addr, 2).unwrap();
|
||||
assert_eq!(&p[..4], &addr.to_le_bytes());
|
||||
assert_eq!(p[4], 2);
|
||||
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
|
||||
}
|
||||
let unpack = |b: &[u8]| -> (u16, u16) {
|
||||
let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8);
|
||||
let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4);
|
||||
(a, y)
|
||||
};
|
||||
// Left: max-above ++ center ++ min-below.
|
||||
let l = spi_flash_read(0x603D, 9).unwrap();
|
||||
let (data, hdr) = (&l[5..], &l[..5]);
|
||||
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
|
||||
let (max_above, _) = unpack(&data[0..3]);
|
||||
let (center, _) = unpack(&data[3..6]);
|
||||
let (min_below, _) = unpack(&data[6..9]);
|
||||
assert_eq!(center, STICK_CENTER);
|
||||
assert!(center - min_below < center && center < center + max_above);
|
||||
// Right: center ++ min-below ++ max-above.
|
||||
let r = spi_flash_read(0x6046, 9).unwrap();
|
||||
let (rc, _) = unpack(&r[5..8]);
|
||||
assert_eq!(rc, STICK_CENTER);
|
||||
// IMU: offsets 0, driver-default scales — the identity calibration.
|
||||
let imu = spi_flash_read(0x6020, 24).unwrap();
|
||||
let d = &imu[5..];
|
||||
assert_eq!(&d[0..6], &[0; 6]);
|
||||
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
|
||||
assert_eq!(&d[12..18], &[0; 6]);
|
||||
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
|
||||
// Unmapped range → None.
|
||||
assert!(spi_flash_read(0x6050, 12).is_none());
|
||||
}
|
||||
|
||||
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
|
||||
#[test]
|
||||
fn motion_units() {
|
||||
let mut st = SwitchState::neutral();
|
||||
// 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096.
|
||||
st.apply_motion([2000, 0, -2000], [10000, -10000, 0]);
|
||||
assert_eq!(st.gyro, [1424, 0, -1424]);
|
||||
assert_eq!(st.accel, [4096, -4096, 0]);
|
||||
}
|
||||
|
||||
/// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the
|
||||
/// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak.
|
||||
#[test]
|
||||
fn rumble_decode() {
|
||||
// Neutral per the driver's tables: freq defaults + amp 0.
|
||||
let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&neutral), (0, 0));
|
||||
// Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0).
|
||||
let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&left_max), (65535, 0));
|
||||
// Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644.
|
||||
let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40];
|
||||
assert_eq!(decode_rumble(&right_mid), (0, 7644));
|
||||
// The freq bit riding data[1] bit0 must not disturb the amplitude index.
|
||||
let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&with_freq_bit).0, 7644);
|
||||
// Short slice → silence, not a panic.
|
||||
assert_eq!(decode_rumble(&[0x10; 4]), (0, 0));
|
||||
}
|
||||
|
||||
/// Output-report parse: the three shapes the driver sends.
|
||||
#[test]
|
||||
fn parse_output_shapes() {
|
||||
assert!(matches!(
|
||||
parse_output(&[0x80, 0x02]),
|
||||
Some(SwitchOutput::UsbCmd(0x02))
|
||||
));
|
||||
let mut sub = vec![0x01, 0x05];
|
||||
sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]);
|
||||
sub.push(0x10); // subcmd id
|
||||
sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args
|
||||
match parse_output(&sub) {
|
||||
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||
assert_eq!(id, 0x10);
|
||||
assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]);
|
||||
assert_eq!(rumble, (0, 0));
|
||||
}
|
||||
_ => panic!("expected subcmd"),
|
||||
}
|
||||
let mut rum = vec![0x10, 0x06];
|
||||
rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]);
|
||||
assert!(matches!(
|
||||
parse_output(&rum),
|
||||
Some(SwitchOutput::Rumble((65535, 0)))
|
||||
));
|
||||
assert!(parse_output(&[0x21]).is_none());
|
||||
assert!(parse_output(&[]).is_none());
|
||||
}
|
||||
|
||||
/// Player lights: solid + flashing nibbles both count as lit.
|
||||
#[test]
|
||||
fn player_lights() {
|
||||
assert_eq!(player_leds_bits(0x01), 0b0001);
|
||||
assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1
|
||||
assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010);
|
||||
}
|
||||
|
||||
/// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10].
|
||||
#[test]
|
||||
fn device_info_shape() {
|
||||
let mac = switch_mac(3);
|
||||
let p = device_info_payload(&mac);
|
||||
assert_eq!(p[2], 0x03);
|
||||
assert_eq!(&p[4..10], &mac);
|
||||
assert_eq!(mac[5], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! The generic stateful virtual-pad manager ([`UhidManager`]) shared by the five backends that
|
||||
//! keep a full per-pad report state (Linux UHID DualSense / DualShock 4 / Steam Deck, Windows UMDF
|
||||
//! DualSense / DualShock 4): event routing, the frame merge, rich-input application, the silence
|
||||
//! heartbeat, and the feedback pump with rumble + hidout dedup are written once here; a backend
|
||||
//! supplies only its per-controller pieces via [`PadProto`]. The stateless backends (Linux uinput,
|
||||
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
|
||||
//! use [`PadSlots`] directly instead.
|
||||
|
||||
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS};
|
||||
use crate::inject::dualsense_proto::HidoutDedup;
|
||||
use crate::inject::pad_slots::PadSlots;
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What one feedback pass extracted from a pad's driver/kernel channel. `rumble` rides the
|
||||
/// universal 0xCA plane (deduped against the last-forwarded level); `hidout` carries the rich
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
}
|
||||
|
||||
/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
|
||||
/// share because it differs per protocol: the transport open, the report-state model and its
|
||||
/// GameStream/rich-input mappers, the state write, and the feedback poll.
|
||||
///
|
||||
/// The `&mut self` receivers let a backend carry configuration (the Steam-paddle remap policy, a
|
||||
/// pad identity); most implementations are otherwise stateless.
|
||||
pub trait PadProto {
|
||||
/// The per-pad transport (a UHID fd, a UMDF shared-memory channel, the Deck transport enum).
|
||||
type Pad;
|
||||
/// The pad's full report state (`DsState`, `SteamState`) — `Copy` like both of those, so the
|
||||
/// manager can hand a snapshot to [`write_state`](Self::write_state) without borrow gymnastics.
|
||||
type State: Copy;
|
||||
|
||||
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"`.
|
||||
const LABEL: &'static str;
|
||||
/// Device name in the create-failure line ("virtual `<DEVICE>` creation failed …").
|
||||
const DEVICE: &'static str;
|
||||
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
|
||||
const CREATE_HINT: &'static str;
|
||||
|
||||
/// Open the virtual pad for wire index `idx`, logging its own success line (it knows the
|
||||
/// transport detail worth printing); failures are logged by the manager's create gate.
|
||||
fn open(&mut self, idx: u8) -> Result<Self::Pad>;
|
||||
/// The all-neutral report state a fresh or unplugged pad (re)starts from.
|
||||
fn neutral(&self) -> Self::State;
|
||||
/// Fold one decoded button/stick frame into a new state, preserving from `prev` every field
|
||||
/// that arrives on the rich plane instead (touch contacts / clicks, motion) — the G2 hook, in
|
||||
/// one place per backend. Paddle remap policy is applied here too.
|
||||
fn merge_frame(&self, prev: &Self::State, f: &GamepadFrame) -> Self::State;
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to the state.
|
||||
fn apply_rich(&self, st: &mut Self::State, rich: RichInput);
|
||||
/// Write the full state to the pad (best-effort; the next frame or heartbeat re-syncs).
|
||||
fn write_state(&self, pad: &mut Self::Pad, st: &Self::State);
|
||||
/// Poll the pad's driver/kernel channel: answer any pending handshake and return the feedback
|
||||
/// it carried. `idx` is the wire pad index (the DualSense GET_REPORT replies need it).
|
||||
fn service(&self, pad: &mut Self::Pad, idx: u8) -> PadFeedback;
|
||||
/// Whether this pad needs a heartbeat write NOW regardless of the silence gap (the Steam
|
||||
/// backend streams through its gamepad-mode-entry pulse).
|
||||
fn force_heartbeat(&self, _pad: &Self::Pad) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual pads of one stateful backend, driven from decoded controller events — the shared
|
||||
/// skeleton of the five UHID/UMDF managers. Method surface (`new` / `handle` / `apply_rich` /
|
||||
/// `pump` / `heartbeat`) is exactly what the session input thread already drives, so each backend
|
||||
/// re-exports itself as a `pub type … = UhidManager<…Proto>;` alias.
|
||||
pub struct UhidManager<B: PadProto> {
|
||||
backend: B,
|
||||
slots: PadSlots<B::Pad>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
|
||||
state: Vec<B::State>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last rich feedback forwarded per pad, so an output report that only changed the rumble
|
||||
/// doesn't re-send unchanged lightbar/LED/trigger state.
|
||||
hidout_dedup: Vec<HidoutDedup>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
}
|
||||
|
||||
impl<B: PadProto + Default> UhidManager<B> {
|
||||
pub fn new() -> UhidManager<B> {
|
||||
UhidManager::with_backend(B::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: PadProto + Default> Default for UhidManager<B> {
|
||||
fn default() -> UhidManager<B> {
|
||||
UhidManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: PadProto> UhidManager<B> {
|
||||
pub fn with_backend(backend: B) -> UhidManager<B> {
|
||||
let state = (0..MAX_PADS).map(|_| backend.neutral()).collect();
|
||||
UhidManager {
|
||||
backend,
|
||||
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
|
||||
state,
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival ({})", B::LABEL);
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields
|
||||
// (touch + motion arrive separately and must survive a button-only frame).
|
||||
self.state[idx] = self.backend.merge_frame(&self.state[idx], f);
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad (a controller must have
|
||||
/// 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, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.slots.get(idx).is_none() {
|
||||
return;
|
||||
}
|
||||
self.backend.apply_rich(&mut self.state[idx], rich);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` (or the backend
|
||||
/// forces a write). The UHID/UMDF drivers treat a multi-second input silence — a held-steady
|
||||
/// stick produces no wire events — as an unplugged controller; re-sending the current state is
|
||||
/// idempotent (a stale-but-correct frame, never a phantom input).
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get(i) else {
|
||||
continue;
|
||||
};
|
||||
if self.backend.force_heartbeat(pad)
|
||||
|| now.duration_since(self.last_write[i]) >= max_gap
|
||||
{
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
|
||||
/// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
|
||||
/// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
|
||||
/// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
|
||||
/// handshakes block until answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
let fb = self.backend.service(pad, i as u8);
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the pad's current state (if it exists) and reset its heartbeat clock — on every write
|
||||
/// (real input or heartbeat), so an actively-used pad emits no extra reports.
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
self.backend.write_state(pad, &st);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Gate-checked create; a FRESH pad starts from neutral state + re-armed dedups.
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
let backend = &mut self.backend;
|
||||
if self.slots.ensure(idx, |i| backend.open(i)) {
|
||||
self.reset_pad(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
|
||||
/// (re)connect starts from scratch and is always forwarded.
|
||||
fn reset_pad(&mut self, idx: usize) {
|
||||
self.state[idx] = self.backend.neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Scripted mock: `open` fails while `fail_opens > 0`; `service` replays canned feedback;
|
||||
/// `MockState` carries a marker for the frame-merge preserve check.
|
||||
#[derive(Default)]
|
||||
struct MockProto {
|
||||
fail_opens: RefCell<u32>,
|
||||
feedback: RefCell<Vec<PadFeedback>>,
|
||||
force_hb: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
struct MockState {
|
||||
buttons: u32,
|
||||
/// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`,
|
||||
/// must survive `merge_frame`.
|
||||
rich_marker: u16,
|
||||
}
|
||||
|
||||
/// Per-pad transport stub recording every state write.
|
||||
#[derive(Default)]
|
||||
struct MockPad {
|
||||
writes: RefCell<Vec<MockState>>,
|
||||
}
|
||||
|
||||
impl PadProto for MockProto {
|
||||
type Pad = MockPad;
|
||||
type State = MockState;
|
||||
const LABEL: &'static str = "Mock";
|
||||
const DEVICE: &'static str = "mock pad";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, _idx: u8) -> Result<MockPad> {
|
||||
let mut fails = self.fail_opens.borrow_mut();
|
||||
if *fails > 0 {
|
||||
*fails -= 1;
|
||||
anyhow::bail!("scripted open failure");
|
||||
}
|
||||
Ok(MockPad::default())
|
||||
}
|
||||
fn neutral(&self) -> MockState {
|
||||
MockState::default()
|
||||
}
|
||||
fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState {
|
||||
MockState {
|
||||
buttons: f.buttons,
|
||||
rich_marker: prev.rich_marker, // the preserve-rich-fields contract
|
||||
}
|
||||
}
|
||||
fn apply_rich(&self, st: &mut MockState, rich: RichInput) {
|
||||
if let RichInput::Touchpad { x, .. } = rich {
|
||||
st.rich_marker = x;
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut MockPad, st: &MockState) {
|
||||
pad.writes.borrow_mut().push(*st);
|
||||
}
|
||||
fn service(&self, _pad: &mut MockPad, _idx: u8) -> PadFeedback {
|
||||
let mut fb = self.feedback.borrow_mut();
|
||||
if fb.is_empty() {
|
||||
PadFeedback::default()
|
||||
} else {
|
||||
fb.remove(0)
|
||||
}
|
||||
}
|
||||
fn force_heartbeat(&self, _pad: &MockPad) -> bool {
|
||||
self.force_hb
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(idx: i16, mask: u16, buttons: u32) -> GamepadEvent {
|
||||
GamepadEvent::State(GamepadFrame {
|
||||
index: idx,
|
||||
active_mask: mask,
|
||||
buttons,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn touch(pad: u8, x: u16) -> RichInput {
|
||||
RichInput::Touchpad {
|
||||
pad,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x,
|
||||
y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn mgr() -> UhidManager<MockProto> {
|
||||
UhidManager::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrival_eager_creates_the_pad() {
|
||||
// G10 as a generic regression test: Arrival must build the device before the first frame.
|
||||
let mut m = mgr();
|
||||
m.handle(&GamepadEvent::Arrival {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_frame_preserves_rich_fields_and_writes_merged_state() {
|
||||
// G2 as a generic regression test: rich-plane state must survive a button-only frame.
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
m.apply_rich(touch(0, 777));
|
||||
m.handle(&frame(0, 0b1, 0xA));
|
||||
let pad = m.slots.get(0).unwrap();
|
||||
let writes = pad.writes.borrow();
|
||||
let last = writes.last().unwrap();
|
||||
assert_eq!(last.buttons, 0xA);
|
||||
assert_eq!(last.rich_marker, 777); // preserved across the merge
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_frame_never_recreates_the_pad_it_swept() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(1, 0b10, 0));
|
||||
assert!(m.slots.get(1).is_some());
|
||||
// Bit 1 cleared and the frame IS pad 1's removal — sweep, then early-return (no ensure).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_event_for_an_absent_pad_is_dropped_and_never_creates() {
|
||||
let mut m = mgr();
|
||||
m.apply_rich(touch(3, 42));
|
||||
assert!(m.slots.get(3).is_none());
|
||||
// …and it left no state behind: a later create starts truly neutral.
|
||||
m.handle(&frame(3, 0b1000, 0));
|
||||
assert_eq!(m.state[3].rich_marker, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_failure_backs_off_then_state_still_tracks() {
|
||||
let mut m = mgr();
|
||||
*m.backend.fail_opens.borrow_mut() = 1;
|
||||
m.handle(&frame(0, 0b1, 0x1));
|
||||
// Open failed: no pad, but the merged state is tracked (matching the old managers).
|
||||
assert!(m.slots.get(0).is_none());
|
||||
assert_eq!(m.state[0].buttons, 0x1);
|
||||
// Next frame inside the backoff window: still no pad, no panic.
|
||||
m.handle(&frame(0, 0b1, 0x3));
|
||||
assert!(m.slots.get(0).is_none());
|
||||
assert_eq!(m.state[0].buttons, 0x3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_dedup_forwards_changes_only_and_rearms_on_recreate() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
let rumble = |r| PadFeedback {
|
||||
rumble: Some(r),
|
||||
hidout: Vec::new(),
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again.
|
||||
m.handle(&frame(0, 0b0, 0));
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidout_dedup_drops_exact_repeats() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
r,
|
||||
g: 0,
|
||||
b: 0,
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: None,
|
||||
hidout: vec![led(10), led(10), led(20)],
|
||||
}];
|
||||
let out = RefCell::new(0u32);
|
||||
m.pump(
|
||||
|_, _, _| {},
|
||||
|_| {
|
||||
*out.borrow_mut() += 1;
|
||||
},
|
||||
);
|
||||
assert_eq!(out.into_inner(), 2); // 10 forwarded once, 20 forwarded; the repeat dropped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_reemits_silent_pads_and_honors_force() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0x5));
|
||||
let writes = |m: &UhidManager<MockProto>| m.slots.get(0).unwrap().writes.borrow().len();
|
||||
let after_frame = writes(&m);
|
||||
// A pad written just now is NOT re-emitted under a huge gap…
|
||||
m.heartbeat(Duration::from_secs(3600));
|
||||
assert_eq!(writes(&m), after_frame);
|
||||
// …but a zero gap counts it as silent and re-emits the CURRENT state.
|
||||
m.heartbeat(Duration::ZERO);
|
||||
assert_eq!(writes(&m), after_frame + 1);
|
||||
assert_eq!(
|
||||
m.slots
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.writes
|
||||
.borrow()
|
||||
.last()
|
||||
.unwrap()
|
||||
.buttons,
|
||||
0x5
|
||||
);
|
||||
// The backend's force flag overrides the gap entirely (the Steam mode-entry pulse).
|
||||
m.backend.force_hb = true;
|
||||
m.heartbeat(Duration::from_secs(3600));
|
||||
assert_eq!(writes(&m), after_frame + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
|
||||
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
|
||||
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
|
||||
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
|
||||
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
|
||||
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
|
||||
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
|
||||
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
|
||||
//! already handles).
|
||||
|
||||
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
|
||||
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
|
||||
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
|
||||
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
|
||||
/// No remap config — every wire paddle has a native slot.
|
||||
#[derive(Default)]
|
||||
pub struct DsEdgeWinProto;
|
||||
|
||||
impl PadProto for DsEdgeWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense Edge/Windows";
|
||||
const DEVICE: &'static str = "DualSense Edge";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense Edge created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
||||
/// (rebuilt from every button frame, so no extra persistence).
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense Edge pads of a session — the Windows analogue of
|
||||
/// [`DualSenseEdgeManager`](crate::inject::dualsense::DualSenseEdgeManager), with the same method
|
||||
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
|
||||
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
|
||||
@@ -18,17 +18,16 @@
|
||||
//! must already be installed; the installer stages it.)
|
||||
|
||||
use super::dualsense_proto::{
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN,
|
||||
DS_TOUCH_H, DS_TOUCH_W,
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
||||
DS_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{anyhow, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
use windows::core::{w, GUID, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
@@ -56,11 +55,14 @@ pub(super) const OFF_DRIVER_PROTO: usize =
|
||||
pub(super) const OFF_PAD_INDEX: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
||||
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
|
||||
struct DsWinPad {
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
/// Linux pads.
|
||||
pub struct DsWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
@@ -86,6 +88,11 @@ pub(super) struct SwDeviceProfile<'a> {
|
||||
pub hwid: &'a str,
|
||||
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
||||
pub usb_vid_pid: &'a str,
|
||||
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
|
||||
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
|
||||
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
|
||||
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
|
||||
pub usb_mi: Option<u8>,
|
||||
/// Device description shown in Device Manager.
|
||||
pub description: &'a str,
|
||||
}
|
||||
@@ -124,8 +131,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
};
|
||||
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
|
||||
let usb = format!("USB\\{}", p.usb_vid_pid);
|
||||
let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
|
||||
let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
|
||||
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
|
||||
let hwids = multi_sz(&[
|
||||
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
||||
usb_rev.as_str(),
|
||||
@@ -227,20 +235,57 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
||||
Ok((hsw, ctx.instance_id()))
|
||||
}
|
||||
|
||||
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
|
||||
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
|
||||
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
|
||||
pub(super) struct WinDsIdentity {
|
||||
/// `device_type` stamped into the section (the driver picks its HID identity off it).
|
||||
pub devtype: u8,
|
||||
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
|
||||
pub instance_prefix: &'static str,
|
||||
/// The INF-matched hardware id.
|
||||
pub hwid: &'static str,
|
||||
/// The USB VID&PID token for the synthesized bus identity.
|
||||
pub usb_vid_pid: &'static str,
|
||||
/// Device Manager description.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl WinDsIdentity {
|
||||
pub(super) const fn dualsense() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: 0,
|
||||
instance_prefix: "pf_pad",
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn dualsense_edge() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: DEVTYPE_DUALSENSE_EDGE,
|
||||
instance_prefix: "pf_edge",
|
||||
hwid: "pf_dualsenseedge",
|
||||
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||
description: "punktfunk Virtual DualSense Edge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DsWinPad {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
||||
/// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
|
||||
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
|
||||
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
||||
fn open(index: u8) -> Result<DsWinPad> {
|
||||
/// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
|
||||
/// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
|
||||
/// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
|
||||
/// removes it (`SwDeviceClose`).
|
||||
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then
|
||||
// the magic LAST (the driver only accepts the section once magic is set). The device-type
|
||||
// stays 0 (DualSense — the section arrives zeroed).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = id.devtype;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
@@ -250,19 +295,20 @@ impl DsWinPad {
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
||||
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
|
||||
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||
let inst = format!("pf_pad_{index}");
|
||||
// rare failure we keep the section + data plane and fall back to an out-of-band devnode
|
||||
// (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||
let inst = format!("{}_{index}", id.instance_prefix);
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_index: index,
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
hwid: id.hwid,
|
||||
usb_vid_pid: id.usb_vid_pid,
|
||||
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
|
||||
description: id.description,
|
||||
}) {
|
||||
Ok((h, id)) => (Some(h), id),
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
|
||||
tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
@@ -274,8 +320,8 @@ impl DsWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_dualsense",
|
||||
"pf_dualsense.inf",
|
||||
id.hwid,
|
||||
"pf_dualsense.inf", // one driver package serves every PS identity
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
@@ -287,7 +333,7 @@ impl DsWinPad {
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||
fn write_state(&mut self, st: &DsState) {
|
||||
pub(super) fn write_state(&mut self, st: &DsState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(1);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
@@ -317,7 +363,7 @@ impl DsWinPad {
|
||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
||||
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
||||
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
self.channel.pump();
|
||||
let mut fb = DsFeedback::default();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
@@ -351,180 +397,158 @@ impl DsWinPad {
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
|
||||
/// thread drives either backend identically.
|
||||
pub struct DualSenseWindowsManager {
|
||||
pads: Vec<Option<DsWinPad>>,
|
||||
state: Vec<DsState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
|
||||
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
|
||||
hidout_dedup: Vec<HidoutDedup>,
|
||||
last_write: Vec<Instant>,
|
||||
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
|
||||
/// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||
pub struct DsWinProto {
|
||||
/// 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. Parity with `linux/dualsense.rs`.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualSenseWindowsManager {
|
||||
fn default() -> DualSenseWindowsManager {
|
||||
DualSenseWindowsManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualSenseWindowsManager {
|
||||
pub fn new() -> DualSenseWindowsManager {
|
||||
DualSenseWindowsManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
gate: PadGate::new(),
|
||||
impl Default for DsWinProto {
|
||||
fn default() -> DsWinProto {
|
||||
DsWinProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualSense/Windows)");
|
||||
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 (DualSense/Windows)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.hidout_dedup[i].clear();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
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, exactly as
|
||||
// `linux/dualsense.rs` does.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
impl PadProto for DsWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense/Windows";
|
||||
const DEVICE: &'static str = "DualSense";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// 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, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
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();
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's current report if it's been silent for `max_gap` (the driver's timer
|
||||
/// streams whatever's in the section, so this just keeps the section fresh / future-proofs parity
|
||||
/// with the UHID backend's heartbeat).
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match DsWinPad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (Windows UMDF shm channel)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
|
||||
/// only on change (universal 0xCA plane); `hidout` fires for each rich DualSense feedback event
|
||||
/// (lightbar / player LEDs / adaptive triggers — 0xCD plane).
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service(i as u8);
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (the game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
|
||||
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
|
||||
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
|
||||
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
|
||||
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
|
||||
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
|
||||
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
|
||||
let mut neutral = [0u8; 64];
|
||||
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
||||
// FIRST, magic LAST — the same publish order the session pads use.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_deckspike_{index}");
|
||||
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The Deck's controller interface — the promotion gate the first spike run hit
|
||||
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck (spike)",
|
||||
})?;
|
||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||
channel.deliver_eager(std::time::Duration::from_millis(1500));
|
||||
println!(
|
||||
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
|
||||
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
|
||||
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
|
||||
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
|
||||
applies verbatim — document and keep the SteamDeck->DualSense Windows fold)."
|
||||
);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
|
||||
let mut last_out_seq = 0u32;
|
||||
while std::time::Instant::now() < deadline {
|
||||
channel.pump();
|
||||
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != last_out_seq {
|
||||
last_out_seq = seq;
|
||||
let mut out = [0u8; 16];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
16,
|
||||
)
|
||||
};
|
||||
println!(" output report from a client (Steam?): {out:02x?}");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
println!("deck-windows-spike: done (devnode removed on exit)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
||||
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
||||
/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
|
||||
/// backend's silence heartbeat.
|
||||
pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
|
||||
|
||||
@@ -16,15 +16,16 @@ use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::PadChannel;
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
struct Ds4WinPad {
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
/// Linux pads.
|
||||
pub struct Ds4WinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
@@ -63,6 +64,7 @@ impl Ds4WinPad {
|
||||
container_index: index,
|
||||
hwid: "pf_dualshock4",
|
||||
usb_vid_pid: "VID_054C&PID_09CC",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual DualShock 4",
|
||||
}) {
|
||||
Ok((h, id)) => (Some(h), id),
|
||||
@@ -141,180 +143,96 @@ impl Ds4WinPad {
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
|
||||
/// Windows DualSense manager so the session input thread drives either backend identically.
|
||||
pub struct DualShock4WindowsManager {
|
||||
pads: Vec<Option<Ds4WinPad>>,
|
||||
state: Vec<DsState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_led: Vec<Option<(u8, u8, u8)>>,
|
||||
last_write: Vec<Instant>,
|
||||
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// The Windows-DualShock-4 half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open (device-type 1), the same [`DsState`] mappers as `linux/dualshock4.rs`, and
|
||||
/// the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in
|
||||
/// [`UhidManager`]; the lightbar dedup that used to be a bespoke `last_led` vec now rides the
|
||||
/// shared `HidoutDedup` (identical semantics — `Led` is compared against the last-forwarded value
|
||||
/// and re-armed on create/unplug).
|
||||
pub struct Ds4WinProto {
|
||||
/// 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. Parity with `linux/dualshock4.rs`.
|
||||
remap: crate::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualShock4WindowsManager {
|
||||
fn default() -> DualShock4WindowsManager {
|
||||
DualShock4WindowsManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualShock4WindowsManager {
|
||||
pub fn new() -> DualShock4WindowsManager {
|
||||
DualShock4WindowsManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
gate: PadGate::new(),
|
||||
impl Default for Ds4WinProto {
|
||||
fn default() -> Ds4WinProto {
|
||||
Ds4WinProto {
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)");
|
||||
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 (DualShock 4/Windows)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_led[i] = None;
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
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, exactly as
|
||||
// `linux/dualshock4.rs` does.
|
||||
let buttons =
|
||||
crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
impl PadProto for Ds4WinProto {
|
||||
type Pad = Ds4WinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualShock 4/Windows";
|
||||
const DEVICE: &'static str = "DualShock 4";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<Ds4WinPad> {
|
||||
let p = Ds4WinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// 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, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
self.write(idx);
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
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();
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||
// 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(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the
|
||||
/// other backends' heartbeat — keeps the section fresh).
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match Ds4WinPad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (Windows UMDF shm channel)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_led[idx] = None;
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)`
|
||||
/// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service();
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = fb.led {
|
||||
if self.last_led[i] != Some(rgb) {
|
||||
self.last_led[i] = Some(rgb);
|
||||
hidout(HidOutput::Led {
|
||||
pad: i as u8,
|
||||
r: rgb.0,
|
||||
g: rgb.1,
|
||||
b: rgb.2,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the
|
||||
/// lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive triggers).
|
||||
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service();
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface (via
|
||||
/// the shared [`UhidManager`]) as the Windows DualSense manager so the session input thread drives
|
||||
/// either backend identically.
|
||||
pub type DualShock4WindowsManager = UhidManager<Ds4WinProto>;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use crate::inject::pad_gate::PadGate;
|
||||
use crate::inject::pad_slots::PadSlots;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
@@ -256,15 +256,12 @@ impl XusbWinPad {
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
pub struct GamepadManager {
|
||||
pads: Vec<Option<XusbWinPad>>,
|
||||
slots: PadSlots<XusbWinPad>,
|
||||
last_rumble: Vec<(u8, u8)>,
|
||||
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
|
||||
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
|
||||
/// const's docs.
|
||||
last_active: Vec<Instant>,
|
||||
/// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of
|
||||
/// permanently disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
@@ -276,32 +273,24 @@ impl Default for GamepadManager {
|
||||
impl GamepadManager {
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
slots: PadSlots::new(
|
||||
"Xbox 360/Windows",
|
||||
"Xbox 360",
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)",
|
||||
),
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
|
||||
gate: PadGate::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
match XusbWinPad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Xbox 360 created (Windows XUSB companion)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_active[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
if self.slots.ensure(idx, XusbWinPad::open) {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Xbox 360 created (Windows XUSB companion)"
|
||||
);
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_active[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,15 +301,14 @@ impl GamepadManager {
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index.max(0) as usize;
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
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 (Xbox 360/Windows)");
|
||||
*slot = None;
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
@@ -329,7 +317,7 @@ impl GamepadManager {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
pad.write_state(
|
||||
(f.buttons & 0xffff) as u16,
|
||||
f.left_trigger,
|
||||
@@ -348,10 +336,7 @@ impl GamepadManager {
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
// activity clock even when the level is unchanged, so a rumble it keeps asserting
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of
|
||||
//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec
|
||||
//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the
|
||||
//! `XInput`/rich mappers, the `0xEB` rumble parser).
|
||||
//!
|
||||
//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3),
|
||||
//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB
|
||||
//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass
|
||||
//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then
|
||||
//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`,
|
||||
//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs +
|
||||
//! trackpads + gyro + back grips through Steam's own remapping.
|
||||
//!
|
||||
//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via
|
||||
//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's
|
||||
//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape
|
||||
//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux
|
||||
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
|
||||
|
||||
use super::dualsense_windows::{
|
||||
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
|
||||
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::gamepad_raii::PadChannel;
|
||||
use super::steam_proto::{
|
||||
neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN,
|
||||
};
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||
pub struct DeckWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
seq: u32,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
impl DeckWinPad {
|
||||
/// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the
|
||||
/// neutral Deck frame + the magic LAST, then spawn the `pf_deck_<index>` devnode with the
|
||||
/// `MI_02` USB identity Steam's promotion gate requires.
|
||||
fn open(index: u8) -> Result<DeckWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(
|
||||
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
|
||||
neutral_deck_report(),
|
||||
);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_deck_{index}");
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
||||
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
||||
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(DeckWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_steamdeck",
|
||||
"pf_dualsense.inf", // one driver package serves every identity
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the Deck state frame and publish it to the section's input slot.
|
||||
fn write_state(&mut self, st: &SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, st, self.seq);
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
r.as_ptr(),
|
||||
self.channel.data_base().add(OFF_INPUT),
|
||||
r.len(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
|
||||
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
|
||||
/// ticks the sealed-channel delivery and the driver-attach health watcher.
|
||||
fn service(&mut self) -> Option<(u16, u16)> {
|
||||
self.channel.pump();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
};
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq = unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||
};
|
||||
if seq == self.last_out_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
self.channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
64,
|
||||
)
|
||||
};
|
||||
parse_steam_output(&out).rumble
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel
|
||||
/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend,
|
||||
/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup)
|
||||
/// lives in [`UhidManager`].
|
||||
#[derive(Default)]
|
||||
pub struct DeckWinProto;
|
||||
|
||||
impl PadProto for DeckWinProto {
|
||||
type Pad = DeckWinPad;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Deck/Windows";
|
||||
const DEVICE: &'static str = "Steam Deck";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DeckWinPad> {
|
||||
let p = DeckWinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion +
|
||||
/// pad clicks arrive separately and must survive a button-only frame) — identical to the
|
||||
/// Linux `SteamProto::merge_frame`.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||
) -> SteamState {
|
||||
use super::steam_proto::btn;
|
||||
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);
|
||||
s.lpad_click = prev.lpad_click;
|
||||
s.rpad_click = prev.rpad_click;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||
st.apply_rich(rich);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The
|
||||
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
|
||||
/// `hidout` stays empty — parity with the Linux backend.
|
||||
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux
|
||||
/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as
|
||||
/// the other Windows pad managers.
|
||||
pub type SteamDeckWindowsManager = UhidManager<DeckWinProto>;
|
||||
@@ -255,19 +255,28 @@ fn real_main() -> Result<()> {
|
||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
|
||||
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
|
||||
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
|
||||
// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
|
||||
// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
|
||||
// kernels verify the bind + `hidraw` byte 10 instead).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("dualsense-test") => {
|
||||
use inject::dualsense::DualSensePad;
|
||||
use inject::dualsense_proto::DsState;
|
||||
use inject::dualsense::{DsUhidIdentity, DualSensePad};
|
||||
use inject::dualsense_proto::{edge_paddle_bits, DsState};
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let (identity, label) = if edge {
|
||||
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
|
||||
} else {
|
||||
(DsUhidIdentity::dualsense(), "DualSense")
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
let mut pad =
|
||||
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?;
|
||||
let mut pad = DualSensePad::open(0, &identity)
|
||||
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
|
||||
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
|
||||
// devices before we start streaming state.
|
||||
let init = Instant::now() + Duration::from_millis(800);
|
||||
@@ -276,7 +285,7 @@ fn real_main() -> Result<()> {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
println!(
|
||||
"virtual DualSense created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
||||
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
||||
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
@@ -292,20 +301,106 @@ fn real_main() -> Result<()> {
|
||||
if last_write.elapsed() >= Duration::from_millis(300) {
|
||||
last_write = Instant::now();
|
||||
i += 1;
|
||||
let buttons = if i % 2 == 0 {
|
||||
let mut buttons = if i % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if edge {
|
||||
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
|
||||
// are visible in evtest / hidraw.
|
||||
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
|
||||
}
|
||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||
pad.write_state(&st).context("write DualSense report")?;
|
||||
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||
if edge {
|
||||
st.buttons[2] |= edge_paddle_bits(buttons);
|
||||
}
|
||||
pad.write_state(&st).context("write report")?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
println!("dualsense-test: done");
|
||||
Ok(())
|
||||
}
|
||||
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
|
||||
// streaming session): answers the full hid-nintendo probe conversation, then cycles the
|
||||
// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
|
||||
// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
|
||||
// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("switchpro-test") => {
|
||||
use inject::switch_pro::SwitchProPad;
|
||||
use inject::switch_proto::SwitchState;
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
use std::time::{Duration, Instant};
|
||||
let mut pad = SwitchProPad::open(0)
|
||||
.context("create virtual Switch Pro Controller via /dev/uhid")?;
|
||||
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
|
||||
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
|
||||
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
|
||||
let init = Instant::now() + Duration::from_millis(2500);
|
||||
let mut hb = Instant::now();
|
||||
while Instant::now() < init {
|
||||
let fb = pad.service(0);
|
||||
for o in fb.hidout {
|
||||
println!(" probe feedback: {o:?}");
|
||||
}
|
||||
if hb.elapsed() >= Duration::from_millis(15) {
|
||||
hb = Instant::now();
|
||||
let _ = pad.write_state(&SwitchState::neutral());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut i, mut last_write) = (0i32, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
let fb = pad.service(0);
|
||||
if let Some((low, high)) = fb.rumble {
|
||||
println!(" rumble from kernel/game: low={low} high={high}");
|
||||
}
|
||||
for o in fb.hidout {
|
||||
println!(" hid output from kernel/game: {o:?}");
|
||||
}
|
||||
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
|
||||
// post-probe subcommand rate limiter fed).
|
||||
if last_write.elapsed() >= Duration::from_millis(15) {
|
||||
last_write = Instant::now();
|
||||
i += 1;
|
||||
let step = i / 20; // change the pressed button every ~300 ms
|
||||
let buttons = if step % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A
|
||||
} else {
|
||||
punktfunk_core::input::gamepad::BTN_B
|
||||
};
|
||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||
pad.write_state(&st).context("write Switch Pro report")?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
println!("switchpro-test: done");
|
||||
Ok(())
|
||||
}
|
||||
// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
|
||||
// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
|
||||
// updated signed driver installed + Steam running. `--seconds N` (default 120).
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("deck-windows-spike") => {
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(120);
|
||||
inject::dualsense_windows::deck_spike_hold(0, secs)
|
||||
}
|
||||
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
|
||||
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
|
||||
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
|
||||
@@ -332,6 +427,18 @@ fn real_main() -> Result<()> {
|
||||
.unwrap_or(0);
|
||||
let ds4 = args.iter().any(|a| a == "--ds4");
|
||||
let xbox = args.iter().any(|a| a == "--xbox");
|
||||
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
|
||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let deck = args.iter().any(|a| a == "--deck");
|
||||
let extra_buttons: u32 = if edge || deck {
|
||||
punktfunk_core::input::gamepad::BTN_PADDLE1
|
||||
| punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
||||
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
||||
macro_rules! drive {
|
||||
@@ -360,7 +467,7 @@ fn real_main() -> Result<()> {
|
||||
last = Instant::now();
|
||||
i += 1;
|
||||
let buttons = if i % 2 == 0 {
|
||||
punktfunk_core::input::gamepad::BTN_A // Cross
|
||||
punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -425,6 +532,16 @@ fn real_main() -> Result<()> {
|
||||
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||
"DualShock 4"
|
||||
);
|
||||
} else if edge {
|
||||
drive!(
|
||||
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||
"DualSense Edge"
|
||||
);
|
||||
} else if deck {
|
||||
drive!(
|
||||
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
||||
"Steam Deck"
|
||||
);
|
||||
} else {
|
||||
drive!(
|
||||
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||
|
||||
@@ -1752,8 +1752,10 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
||||
///
|
||||
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
|
||||
/// - Steam Deck — Linux UHID `hid-steam`.
|
||||
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||
/// minidriver (device-type 0/2/1).
|
||||
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
|
||||
/// (device-type 3, Steam-Input-promoted).
|
||||
///
|
||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||
/// constructs a manager the build lacks.
|
||||
@@ -1771,13 +1773,23 @@ struct Pads {
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
|
||||
}
|
||||
|
||||
impl Pads {
|
||||
@@ -1798,13 +1810,23 @@ impl Pads {
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1855,6 +1877,11 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4
|
||||
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
||||
@@ -1865,6 +1892,16 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => self
|
||||
.switchpro
|
||||
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => self
|
||||
.steamctrl
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
@@ -1879,12 +1916,24 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck_win
|
||||
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
|
||||
.handle(ev),
|
||||
_ => self
|
||||
.xbox360
|
||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||
@@ -1920,6 +1969,12 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.apply_rich(rich)
|
||||
@@ -1931,6 +1986,18 @@ impl Pads {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => {
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
@@ -1938,11 +2005,23 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1967,21 +2046,36 @@ impl Pads {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1996,12 +2090,21 @@ impl Pads {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -2009,9 +2112,15 @@ impl Pads {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2692,14 +2801,21 @@ 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).
|
||||
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
|
||||
// are the N4 spike).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
||||
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
||||
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
||||
// all of that silently.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
||||
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
|
||||
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
|
||||
// gyro + back grips, replacing the old fold to DualSense.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
|
||||
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
|
||||
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
|
||||
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -2712,7 +2828,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
let needs_uhid = matches!(
|
||||
chosen,
|
||||
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
|
||||
GamepadPref::DualSense
|
||||
| GamepadPref::DualSenseEdge
|
||||
| GamepadPref::DualShock4
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
&& std::fs::OpenOptions::new()
|
||||
@@ -2744,7 +2865,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||
/// vhci path as belt and braces.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_steam_controller_present() -> bool {
|
||||
@@ -2756,7 +2877,7 @@ fn physical_steam_controller_present() -> bool {
|
||||
return false;
|
||||
}
|
||||
if std::fs::read_to_string(e.path().join("uevent"))
|
||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK")))
|
||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
||||
{
|
||||
return false; // one of our own virtual Decks
|
||||
}
|
||||
@@ -3103,13 +3224,18 @@ fn service_probes(
|
||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||
/// only the OVERFLOW beyond that is spread in 16-packet chunks across ~90% of the time to
|
||||
/// `deadline`. So a normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added
|
||||
/// latency, while a genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping
|
||||
/// the freeze fix exactly where it's needed (an unpaced line-rate burst overruns the kernel tx
|
||||
/// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
|
||||
/// this is never slower than unpaced.
|
||||
/// only the OVERFLOW beyond that is spread across ~90% of the time to `deadline` in ADAPTIVE
|
||||
/// chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment cap) once
|
||||
/// the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace instead
|
||||
/// of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
|
||||
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
|
||||
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
|
||||
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
|
||||
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
|
||||
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
|
||||
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
|
||||
/// until the next keyframe). With no slack (encode ≈ interval) the budget collapses to 0 and
|
||||
/// even the overflow goes out immediately, so this is never slower than unpaced.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paced_submit(
|
||||
session: &mut Session,
|
||||
@@ -3118,7 +3244,7 @@ fn paced_submit(
|
||||
flags: u32,
|
||||
frame_index: u32,
|
||||
deadline: std::time::Instant,
|
||||
burst_cap: usize,
|
||||
burst_cap: Option<usize>,
|
||||
) -> Result<PaceStat> {
|
||||
let wires = session
|
||||
.seal_frame_at(data, pts_ns, flags, frame_index)
|
||||
@@ -3126,11 +3252,15 @@ fn paced_submit(
|
||||
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
||||
crate::send_pacing::inject_video_drop(&mut refs);
|
||||
let wire_bytes: usize = refs.iter().map(|p| p.len()).sum();
|
||||
let cfg = crate::send_pacing::PaceCfg {
|
||||
burst_bytes: Some(burst_cap),
|
||||
chunk: crate::send_pacing::ChunkPolicy::Fixed(16),
|
||||
burst_bytes: Some(burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024))),
|
||||
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: std::time::Duration::from_micros(500),
|
||||
};
|
||||
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
|
||||
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
|
||||
let mut sock_ns = 0u64;
|
||||
let result = crate::send_pacing::pace_frame(
|
||||
&refs,
|
||||
crate::send_pacing::PaceBudget::UntilDeadline {
|
||||
@@ -3138,10 +3268,16 @@ fn paced_submit(
|
||||
fraction: 0.9,
|
||||
},
|
||||
&cfg,
|
||||
|chunk| session.send_sealed(chunk).map(|_| ()),
|
||||
|chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
},
|
||||
);
|
||||
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||
session.reclaim_wires(wires);
|
||||
session.note_sock_ns(sock_ns);
|
||||
result.map_err(|e| anyhow!("send_sealed: {e:?}"))
|
||||
}
|
||||
|
||||
@@ -3343,7 +3479,7 @@ fn send_loop(
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
stop: Arc<AtomicBool>,
|
||||
perf: bool,
|
||||
burst_cap: usize,
|
||||
burst_cap: Option<usize>,
|
||||
fec_target: Arc<AtomicU8>,
|
||||
stats: SendStats,
|
||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||
@@ -3458,6 +3594,11 @@ fn send_loop(
|
||||
// Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire.
|
||||
let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0;
|
||||
if perf {
|
||||
// Send-thread stage split (Phase 0.4 host half): busy-time sums over this
|
||||
// window, so share-of-core = <stage>_ms / window wall ms. The per-packet ns
|
||||
// figures are the Phase 1.5 gate metric — seal parallelism is warranted only
|
||||
// if seal_ns_pp × pkts/s approaches ~15% of a core at 2 Gbps.
|
||||
let sp = session.take_seal_perf().unwrap_or_default();
|
||||
tracing::info!(
|
||||
tx_mbps = format!("{tx_mbps:.0}"),
|
||||
send_dropped = s.packets_send_dropped - last_send_dropped,
|
||||
@@ -3469,6 +3610,14 @@ fn send_loop(
|
||||
pace_us_max = pace_us.last().copied().unwrap_or(0),
|
||||
immediate_frames,
|
||||
paced_frames,
|
||||
window_ms = format!("{:.0}", secs * 1000.0),
|
||||
fec_ms = format!("{:.2}", sp.fec_ns as f64 / 1e6),
|
||||
seal_ms = format!("{:.2}", sp.seal_ns as f64 / 1e6),
|
||||
sock_ms = format!("{:.2}", sp.sock_ns as f64 / 1e6),
|
||||
fec_ns_pp = sp.fec_ns.checked_div(sp.packets).unwrap_or(0),
|
||||
seal_ns_pp = sp.seal_ns.checked_div(sp.packets).unwrap_or(0),
|
||||
sock_ns_pp = sp.sock_ns.checked_div(sp.packets).unwrap_or(0),
|
||||
sealed_pkts = sp.packets,
|
||||
"perf"
|
||||
);
|
||||
}
|
||||
@@ -3856,13 +4005,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
let _ = &launch;
|
||||
|
||||
let perf = crate::config::config().perf;
|
||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
||||
// only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default.
|
||||
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
|
||||
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the
|
||||
// AU's wire bytes / 4), so the burst stays a bounded fraction of high-rate frames instead
|
||||
// of swallowing them whole (plan Phase 1.3). PUNKTFUNK_PACE_BURST_KB pins an absolute cap.
|
||||
let burst_cap: Option<usize> = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(128)
|
||||
* 1024;
|
||||
.map(|kb| kb * 1024);
|
||||
|
||||
// Encode|send split: this thread captures+encodes (the GPU work) + handles reconfig, and hands
|
||||
// each AU to a dedicated send thread that owns the Session and does FEC+seal+paced-send — so the
|
||||
@@ -4153,47 +4303,67 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||
// several times while we stream) and rebuild the ENCODER ONLY in place — the mode didn't
|
||||
// change, so capture and the virtual output are untouched and the switch costs exactly the
|
||||
// IDR the fresh encoder opens with (the same resync discipline as a mode switch, minus the
|
||||
// pipeline churn). Rates arrive pre-clamped by the control task (`resolve_bitrate_kbps`).
|
||||
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
|
||||
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
|
||||
// `reconfigure_bitrate` (Phase 3.2 — NVENC nvEncReconfigureEncoder / AMF dynamic props /
|
||||
// Vulkan RC control), which keeps the encoder, its reference chain and the in-flight AUs,
|
||||
// so the step costs NOTHING on the wire (no IDR, no forfeit — exactly what the Automatic
|
||||
// controller's doubling climb wants). A backend that can't (libavcodec paths) or a driver
|
||||
// rejection falls back to the full rebuild, which costs the IDR the fresh encoder opens
|
||||
// with (the same resync discipline as a mode switch, minus the pipeline churn) and owns
|
||||
// the bitrate clamping. Rates arrive pre-clamped by the control task
|
||||
// (`resolve_bitrate_kbps`).
|
||||
let mut want_kbps = None;
|
||||
while let Ok(k) = bitrate_rx.try_recv() {
|
||||
want_kbps = Some(k);
|
||||
}
|
||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer rate.
|
||||
let hz = interval_hz(interval);
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
hz,
|
||||
new_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
) {
|
||||
Ok(new_enc) => {
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||
);
|
||||
enc = new_enc;
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a mode-switch
|
||||
// rebuild; the fresh encoder opens on an IDR, so anchor the IDR cooldown too.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
"bitrate-change encoder rebuild failed — keeping the current rate");
|
||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
||||
);
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
||||
} else {
|
||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
|
||||
// rate.
|
||||
let hz = interval_hz(interval);
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
hz,
|
||||
new_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
) {
|
||||
Ok(new_enc) => {
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||
);
|
||||
enc = new_enc;
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a
|
||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
||||
// IDR cooldown too.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
"bitrate-change encoder rebuild failed — keeping the current rate");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5273,12 +5443,44 @@ mod tests {
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||
|
||||
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
|
||||
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
|
||||
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
|
||||
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController, None, true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("steamcontroller"), true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
|
||||
|
||||
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
|
||||
// elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, true, false),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, false, true),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
|
||||
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("switchpro"), true, false),
|
||||
SwitchPro
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
//! deterministic-schedule tests below):
|
||||
//!
|
||||
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
|
||||
//! overflow is paced in fixed 16-packet chunks across 90 % of the time left to the frame
|
||||
//! deadline (no slack ⇒ budget 0 ⇒ never slower than unpaced);
|
||||
//! overflow is paced across 90 % of the time left to the frame deadline in ADAPTIVE chunks:
|
||||
//! 16 packets at today's rates, coarsening just enough that the per-chunk interval clears the
|
||||
//! sleep floor (≤ 64, the GSO-segment cap) once the rate would otherwise skip every sleep —
|
||||
//! so ≥1 Gbps frames still pace instead of blasting (no slack ⇒ budget 0 ⇒ never slower than
|
||||
//! unpaced);
|
||||
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
|
||||
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
|
||||
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
|
||||
@@ -36,6 +39,13 @@ pub(crate) struct PaceStat {
|
||||
pub(crate) enum ChunkPolicy {
|
||||
/// Fixed chunk size; the step count scales with the frame (native: 16).
|
||||
Fixed(usize),
|
||||
/// Rate-adaptive chunk size (native, plan Phase 1.2): `base` packets until the per-chunk
|
||||
/// interval (`budget / steps`) would drop under the sleep floor, then the smallest chunk
|
||||
/// that keeps the interval ≥ floor, capped at `max` (the 64-segment GSO super-buffer
|
||||
/// limit). Zero budget (no slack — the frame blasts anyway) takes `max`: fewest syscalls
|
||||
/// for the same immediate send. Decouples the syscall batch from the pace step so high
|
||||
/// rates keep REAL sleeps between chunks instead of skipping every sub-floor wait.
|
||||
Adaptive { base: usize, max: usize },
|
||||
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
|
||||
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
|
||||
Bounded { min_chunk: usize, max_steps: usize },
|
||||
@@ -72,8 +82,15 @@ pub(crate) struct PaceSchedule {
|
||||
pub(crate) steps: usize,
|
||||
}
|
||||
|
||||
/// Compute the schedule for one frame's wire packets under `cfg`.
|
||||
pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSchedule {
|
||||
/// Compute the schedule for one frame's wire packets under `cfg`. `pace_budget` is the time
|
||||
/// the paced overflow will spread across (resolved by the caller); only
|
||||
/// [`ChunkPolicy::Adaptive`] reads it — the `Fixed`/`Bounded` schedules are budget-independent
|
||||
/// (the pinned legacy planes).
|
||||
pub(crate) fn schedule<T: AsRef<[u8]>>(
|
||||
packets: &[T],
|
||||
cfg: &PaceCfg,
|
||||
pace_budget: Duration,
|
||||
) -> PaceSchedule {
|
||||
let burst_len = match cfg.burst_bytes {
|
||||
None => 0,
|
||||
Some(cap) => {
|
||||
@@ -94,6 +111,20 @@ pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSche
|
||||
let overflow = packets.len() - burst_len;
|
||||
let (chunk, steps) = match cfg.chunk {
|
||||
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
|
||||
ChunkPolicy::Adaptive { base, max } => {
|
||||
let c = if overflow == 0 {
|
||||
base
|
||||
} else if pace_budget.is_zero() {
|
||||
max
|
||||
} else {
|
||||
// interval = budget/steps ≈ budget·c/overflow ≥ sleep_floor ⇔
|
||||
// c ≥ overflow·floor/budget — the smallest such c, clamped to [base, max].
|
||||
let c_min = (overflow as u128 * cfg.sleep_floor.as_nanos())
|
||||
.div_ceil(pace_budget.as_nanos());
|
||||
c_min.clamp(base as u128, max as u128) as usize
|
||||
};
|
||||
(c, overflow.div_ceil(c).max(1))
|
||||
}
|
||||
ChunkPolicy::Bounded {
|
||||
min_chunk,
|
||||
max_steps,
|
||||
@@ -120,7 +151,19 @@ pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
|
||||
mut send: impl FnMut(&[T]) -> Result<(), E>,
|
||||
) -> Result<PaceStat, E> {
|
||||
let start = Instant::now();
|
||||
let sched = schedule(packets, cfg);
|
||||
// Resolve the pace budget up front: adaptive chunk sizing needs it before the burst
|
||||
// leaves. The paced loop below still re-anchors at `pace_start` (after the burst), so the
|
||||
// sleep targets are exactly the legacy math; this entry-time estimate only sizes chunks
|
||||
// (it overshoots the post-burst budget by the burst's few µs — harmless, sub-floor sleeps
|
||||
// are skipped anyway).
|
||||
let budget_est = match budget {
|
||||
PaceBudget::UntilDeadline { deadline, fraction } => deadline
|
||||
.checked_duration_since(start)
|
||||
.unwrap_or_default()
|
||||
.mul_f32(fraction),
|
||||
PaceBudget::Fixed(d) => d,
|
||||
};
|
||||
let sched = schedule(packets, cfg, budget_est);
|
||||
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
|
||||
send(chunk)?;
|
||||
}
|
||||
@@ -257,10 +300,13 @@ mod tests {
|
||||
let pkts = packets(n, len);
|
||||
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
|
||||
let (split, m) = legacy(&sizes, cap);
|
||||
let s = schedule(&pkts, &native_cfg(cap));
|
||||
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
|
||||
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
|
||||
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
|
||||
// Two very different budgets: Fixed schedules must not read the budget at all.
|
||||
for budget in [Duration::ZERO, Duration::from_millis(7)] {
|
||||
let s = schedule(&pkts, &native_cfg(cap), budget);
|
||||
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
|
||||
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
|
||||
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,20 +322,53 @@ mod tests {
|
||||
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
|
||||
let pkts = packets(n, 1024);
|
||||
let (chunk, steps) = legacy_pace_layout(n);
|
||||
let s = schedule(&pkts, &gs_cfg());
|
||||
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
|
||||
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
|
||||
assert_eq!(s.steps, steps, "n={n}: step count");
|
||||
assert!(s.steps <= 12, "n={n}: step count bounded");
|
||||
assert!(s.chunk >= 16, "n={n}: chunk floor");
|
||||
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
|
||||
// Two very different budgets: Bounded schedules must not read the budget at all.
|
||||
for budget in [Duration::ZERO, Duration::from_millis(7)] {
|
||||
let s = schedule(&pkts, &gs_cfg(), budget);
|
||||
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
|
||||
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
|
||||
assert_eq!(s.steps, steps, "n={n}: step count");
|
||||
assert!(s.steps <= 12, "n={n}: step count bounded");
|
||||
assert!(s.chunk >= 16, "n={n}: chunk floor");
|
||||
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
|
||||
}
|
||||
}
|
||||
// The legacy test's exact anchors.
|
||||
let s = schedule(&packets(1, 1024), &gs_cfg());
|
||||
let s = schedule(&packets(1, 1024), &gs_cfg(), Duration::ZERO);
|
||||
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||
let s = schedule(&packets(16, 1024), &gs_cfg());
|
||||
let s = schedule(&packets(16, 1024), &gs_cfg(), Duration::ZERO);
|
||||
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||
assert!(schedule(&packets(610, 1024), &gs_cfg()).steps <= 12);
|
||||
assert!(schedule(&packets(610, 1024), &gs_cfg(), Duration::ZERO).steps <= 12);
|
||||
}
|
||||
|
||||
/// The native plane's Phase-1.2 policy (plan `throughput-beyond-1gbps.md`): 16-packet
|
||||
/// chunks at today's rates, coarsening only when the per-chunk interval would drop under
|
||||
/// the 500 µs sleep floor, capped at the 64-segment GSO super-buffer limit; zero budget
|
||||
/// (blast) takes the cap.
|
||||
#[test]
|
||||
fn adaptive_chunk_coarsens_with_rate() {
|
||||
let cfg = PaceCfg {
|
||||
burst_bytes: Some(12_000),
|
||||
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: Duration::from_micros(500),
|
||||
};
|
||||
// 210 × 1200 B: packets 0..=9 burst (cum hits 12 000 at #10), 200 overflow.
|
||||
let pkts = packets(210, 1200);
|
||||
// Ample budget (100 ms): a 16-packet interval is ≫ floor → base, legacy-identical.
|
||||
let s = schedule(&pkts, &cfg, Duration::from_millis(100));
|
||||
assert_eq!((s.burst_len, s.chunk, s.steps), (10, 16, 13));
|
||||
// 2.5 ms budget: c ≥ 200 × 500 µs / 2.5 ms = 40 → exactly 40, 5 steps × 500 µs each.
|
||||
let s = schedule(&pkts, &cfg, Duration::from_micros(2_500));
|
||||
assert_eq!((s.chunk, s.steps), (40, 5));
|
||||
// 1 ms budget: c ≥ 100 → capped at 64 (the GSO segment limit).
|
||||
let s = schedule(&pkts, &cfg, Duration::from_millis(1));
|
||||
assert_eq!((s.chunk, s.steps), (64, 4));
|
||||
// Zero budget (no slack — the frame blasts): max chunk = fewest syscalls.
|
||||
let s = schedule(&pkts, &cfg, Duration::ZERO);
|
||||
assert_eq!((s.chunk, s.steps), (64, 4));
|
||||
// Whole frame under the cap: no overflow → base chunk for the burst sends.
|
||||
let s = schedule(&packets(5, 1200), &cfg, Duration::ZERO);
|
||||
assert_eq!((s.burst_len, s.chunk, s.steps), (5, 16, 1));
|
||||
}
|
||||
|
||||
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
|
||||
@@ -329,6 +408,27 @@ mod tests {
|
||||
assert_eq!(seen, vec![16, 4]);
|
||||
assert!(!stat.paced);
|
||||
|
||||
// Native adaptive, zero budget: the burst leaves in one ≤64-packet chunk, the overflow
|
||||
// in 64-packet super-chunks (the blast path takes the coarsest syscall batching).
|
||||
let pkts = packets(210, 1200);
|
||||
let mut seen: Vec<usize> = Vec::new();
|
||||
let stat = pace_frame(
|
||||
&pkts,
|
||||
PaceBudget::Fixed(Duration::ZERO),
|
||||
&PaceCfg {
|
||||
burst_bytes: Some(12_000),
|
||||
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: Duration::from_micros(500),
|
||||
},
|
||||
|chunk| {
|
||||
seen.push(chunk.len());
|
||||
Ok::<(), std::io::Error>(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![10, 64, 64, 64, 8]);
|
||||
assert!(stat.paced);
|
||||
|
||||
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
|
||||
let pkts = packets(146, 1024);
|
||||
let mut seen: Vec<usize> = Vec::new();
|
||||
|
||||
@@ -252,6 +252,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||
stop_autologin_sessions();
|
||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||
free_desktop_steam()?;
|
||||
}
|
||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||
@@ -316,6 +319,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||
stop_autologin_sessions();
|
||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
||||
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
||||
free_desktop_steam()?;
|
||||
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||
@@ -894,6 +901,96 @@ fn stop_autologin_sessions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
|
||||
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// B1b: free Steam held by a plain **desktop** session (GNOME/KDE — e.g. a Steam the user opened
|
||||
/// while streaming the desktop). [`stop_autologin_sessions`] only frees `gamescope-session-plus@*`
|
||||
/// autologin units, so a desktop Steam still holds the single instance — a dedicated launch's
|
||||
/// nested `steam` would just forward its URI to it and exit, gamescope would follow its child
|
||||
/// down, and the client would see a black screen while the game launches invisibly on the desktop
|
||||
/// (observed 2026-07-14 on a GNOME host: session-recovery restarted GDM for a desktop stream, the
|
||||
/// user opened Steam there, and the next game-library launch black-screened through all 8 pipeline
|
||||
/// retries). Asks that Steam to quit via `steam -shutdown` (the single-instance IPC, graceful) and
|
||||
/// waits for it to exit; on timeout the spawn fails with an operator-actionable error instead of
|
||||
/// the misleading no-frames retry loop. Steam instances punktfunk owns are exempt — URI forwarding
|
||||
/// into a reused/kept session is the designed path, and another session's live Steam must never be
|
||||
/// torn down from here.
|
||||
fn free_desktop_steam() -> Result<()> {
|
||||
let Some(pid) = desktop_steam_pid() else {
|
||||
return Ok(());
|
||||
};
|
||||
tracing::info!(
|
||||
pid,
|
||||
"freeing Steam: a desktop-session Steam holds the single instance — sending `steam -shutdown`"
|
||||
);
|
||||
let _ = Command::new("steam")
|
||||
.arg("-shutdown")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
let deadline = Instant::now() + STEAM_SHUTDOWN_WAIT;
|
||||
while Instant::now() < deadline {
|
||||
if !pid_running(pid) {
|
||||
tracing::info!(pid, "desktop Steam exited — single instance free");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
bail!(
|
||||
"Steam is already running in the host's desktop session (pid {pid}) and did not exit \
|
||||
within {}s of `steam -shutdown` — close Steam on the host, then launch again",
|
||||
STEAM_SHUTDOWN_WAIT.as_secs()
|
||||
)
|
||||
}
|
||||
|
||||
/// Pid of a live Steam instance running OUTSIDE anything punktfunk owns (i.e. a desktop-session
|
||||
/// Steam), found via `~/.steam/steam.pid` — Steam's own single-instance marker, kept current by
|
||||
/// every fresh instance. `None` when Steam isn't running, the pidfile is stale (pid dead, zombie,
|
||||
/// or recycled by a non-Steam process), or the instance is punktfunk's own: a descendant of this
|
||||
/// host process (a dedicated spawn's nested Steam) or inside the managed [`SESSION_UNIT`] cgroup.
|
||||
fn desktop_steam_pid() -> Option<u32> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
let pid = std::fs::read_to_string(format!("{home}/.steam/steam.pid"))
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())?;
|
||||
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
|
||||
// Steam's own processes report comm `steam` (the ubuntu12_32 binary) or `steam.sh`; anything
|
||||
// else means the pid was recycled since Steam last ran.
|
||||
if !matches!(comm.trim(), "steam" | "steam.sh") || !pid_running(pid) {
|
||||
return None;
|
||||
}
|
||||
if descends_from(pid, std::process::id()) {
|
||||
return None; // our own dedicated spawn's Steam
|
||||
}
|
||||
let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).unwrap_or_default();
|
||||
if cgroup_is_punktfunk_owned(&cgroup) {
|
||||
return None; // the host service's tree or the managed session unit
|
||||
}
|
||||
Some(pid)
|
||||
}
|
||||
|
||||
/// Does this `/proc/<pid>/cgroup` content place the process in a punktfunk-owned unit — the host
|
||||
/// service itself or the host-managed gamescope session? Desktop Steams live in desktop app scopes
|
||||
/// (e.g. `app-gnome-steam-<pid>.scope`) instead. Pure + unit-tested.
|
||||
fn cgroup_is_punktfunk_owned(cgroup: &str) -> bool {
|
||||
cgroup.contains("punktfunk-host.service") || cgroup.contains(&format!("{SESSION_UNIT}.service"))
|
||||
}
|
||||
|
||||
/// Is `pid` alive and not a zombie? (A zombie keeps its `/proc` entry but has already released the
|
||||
/// Steam instance, so waiting on it would spin the full shutdown deadline for nothing.)
|
||||
fn pid_running(pid: u32) -> bool {
|
||||
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
return false;
|
||||
};
|
||||
// Field 3 (state) follows the parenthesized comm — split after the LAST ')' since comm can
|
||||
// itself contain parentheses.
|
||||
stat.rsplit_once(')')
|
||||
.and_then(|(_, rest)| rest.split_whitespace().next())
|
||||
.is_some_and(|state| state != "Z")
|
||||
}
|
||||
|
||||
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
||||
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
||||
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
||||
@@ -1136,6 +1233,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
||||
let start_unit = || -> Result<()> {
|
||||
let status = Command::new("systemd-run")
|
||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
||||
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
|
||||
// that would abort gamescope at startup.
|
||||
.arg("--property=UnsetEnvironment=DISPLAY WAYLAND_DISPLAY")
|
||||
.arg("--setenv=BACKEND=headless")
|
||||
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
||||
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
||||
@@ -1301,7 +1402,16 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
||||
])
|
||||
.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
||||
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
|
||||
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
|
||||
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
|
||||
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
|
||||
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
|
||||
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY");
|
||||
if let Ok(logf) = std::fs::File::create(log) {
|
||||
if let Ok(log2) = logf.try_clone() {
|
||||
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
||||
@@ -1587,7 +1697,10 @@ impl Drop for GamescopeProc {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
||||
MIN_GAMESCOPE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn steam_launch_detection() {
|
||||
@@ -1623,6 +1736,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_steam_cgroup_ownership() {
|
||||
// A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host).
|
||||
assert!(!cgroup_is_punktfunk_owned(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-steam-48605.scope"
|
||||
));
|
||||
// KDE spawns app scopes too; still foreign.
|
||||
assert!(!cgroup_is_punktfunk_owned(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-steam@0f3a.service"
|
||||
));
|
||||
// Our own dedicated spawn tree (Steam nested under the host service).
|
||||
assert!(cgroup_is_punktfunk_owned(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service"
|
||||
));
|
||||
// The host-managed gamescope session unit (SESSION_UNIT).
|
||||
assert!(cgroup_is_punktfunk_owned(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-gamescope.service"
|
||||
));
|
||||
assert!(!cgroup_is_punktfunk_owned(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_banner() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. DualSense/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||
|
||||
## Audio / microphone
|
||||
@@ -138,7 +138,7 @@ notes for context.
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — the dominant lever above ~1 Gbps. On by default; auto-falls back to `sendmmsg`. Set `0` if a NIC/middlebox mishandles GSO. |
|
||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||
@@ -158,7 +158,7 @@ A few knobs are read by the native **clients**, not the host:
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_DECODER` | `software` · `vaapi` (Linux) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, D3D11VA on Windows) with a software fallback. |
|
||||
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck, D3D11VA/Vulkan on Windows) with a software fallback. |
|
||||
|
||||
## Bitrate
|
||||
|
||||
|
||||
@@ -112,15 +112,23 @@
|
||||
// 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.
|
||||
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||
// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||
#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.
|
||||
// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
||||
// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
||||
// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||
|
||||
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||
// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||
#define PUNKTFUNK_GAMEPAD_DUALSENSEEDGE 7
|
||||
|
||||
// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
|
||||
|
||||
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
// (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`.
|
||||
|
||||