Compare commits
@@ -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.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2778,7 +2778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,14 +3039,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
@@ -3059,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3090,7 +3089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3162,7 +3161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3176,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
@@ -35,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? {
|
||||
if (s.hatY >= 0.5f) return NavDir.DOWN
|
||||
if (s.hatX <= -0.5f) return NavDir.LEFT
|
||||
if (s.hatX >= 0.5f) return NavDir.RIGHT
|
||||
return if (abs(s.stickY) >= abs(s.stickX)) {
|
||||
// Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the
|
||||
// vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the
|
||||
// same on every client.
|
||||
return if (abs(s.stickY) > abs(s.stickX)) {
|
||||
when {
|
||||
s.stickY <= -STICK_HIGH -> NavDir.UP
|
||||
s.stickY >= STICK_HIGH -> NavDir.DOWN
|
||||
|
||||
@@ -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
|
||||
@@ -127,12 +128,12 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index, tracks held
|
||||
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
|
||||
// R1) completed on any one pad (a couch user has no keyboard/Back).
|
||||
if (gamepadRouter?.onButton(event, bit) == true) {
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||
// accidental brush of the four buttons no longer quits instantly.
|
||||
gamepadRouter?.onButton(event, bit)
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -180,13 +194,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
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
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
@@ -195,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) {
|
||||
@@ -215,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(),
|
||||
@@ -265,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) {
|
||||
@@ -275,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
|
||||
}
|
||||
}
|
||||
@@ -219,14 +232,31 @@ object Gamepad {
|
||||
),
|
||||
)
|
||||
|
||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
||||
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
|
||||
// tap (press+release inside one batch window) lives only in the historical samples — the
|
||||
// final getAxisValue would show the HAT already back at rest and miss the tap entirely.
|
||||
// Feed every historical HAT sample (oldest→newest) through the same transition logic
|
||||
// before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins:
|
||||
// only the final value matters for an analog axis.)
|
||||
for (h in 0 until event.historySize) {
|
||||
applyHat(
|
||||
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)),
|
||||
sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)),
|
||||
)
|
||||
}
|
||||
applyHat(
|
||||
sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)),
|
||||
sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */
|
||||
private fun applyHat(hx: Int, hy: Int) {
|
||||
if (hx != hatX) {
|
||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||
if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
|
||||
hatX = hx
|
||||
}
|
||||
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
|
||||
if (hy != hatY) {
|
||||
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false)
|
||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||
|
||||
@@ -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
|
||||
@@ -64,10 +75,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
private var rumbleThread: Thread? = null
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
|
||||
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
|
||||
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
|
||||
// race-free. A null value caches "this controller has no vibrator / no controllable lights".
|
||||
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
|
||||
// thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
|
||||
// from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
|
||||
// the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain
|
||||
// HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the
|
||||
// null value that caches "this controller has no vibrator / no controllable lights"). The lock
|
||||
// guards only the map ops — rendering runs on the returned reference outside it; a stale reference
|
||||
// is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd
|
||||
// no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed.
|
||||
private val bindsLock = Any()
|
||||
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||
private val lightBinds = HashMap<Int, LightBind?>()
|
||||
|
||||
@@ -121,14 +138,38 @@ 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.
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
// 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() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||
*/
|
||||
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||
@Suppress("MissingPermission", "NewApi")
|
||||
fun onDeviceRemoved(deviceId: Int) {
|
||||
synchronized(bindsLock) {
|
||||
rumbleBinds.remove(deviceId)?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
|
||||
// ---- Rumble ----
|
||||
@@ -136,10 +177,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
synchronized(bindsLock) {
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||
@@ -173,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)
|
||||
@@ -184,7 +232,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
|
||||
// (XInput/Moonlight convention). Android does not guarantee the order of
|
||||
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
|
||||
// invert the feel: the stronger amplitude drives the physically-lighter motor.
|
||||
// Failure mode is tactile only — both motors still fire, nothing silences or
|
||||
// crashes — so this stays the default pending per-pad on-glass verification (G20).
|
||||
// ids beyond the first two (rare) are left alone here.
|
||||
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||
} else {
|
||||
@@ -210,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
|
||||
@@ -217,9 +294,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero.
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||
VibrationEffect.createOneShot(durationMs, amp)
|
||||
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||
|
||||
// ---- HID output ----
|
||||
|
||||
@@ -268,10 +349,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
private fun lightBindFor(pad: Int): LightBind? {
|
||||
if (Build.VERSION.SDK_INT < 33) return null
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
synchronized(bindsLock) {
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice): LightBind? {
|
||||
@@ -307,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() }
|
||||
}
|
||||
|
||||
@@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
*/
|
||||
var onExitChord: (() -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
|
||||
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||
private val listener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
@@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
|
||||
init {
|
||||
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||
inputManager?.registerInputDeviceListener(listener, mainHandler)
|
||||
// Open a slot for every controller already connected when the session starts — the pads that
|
||||
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
@@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
/**
|
||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||
* slot's pad index, tracks held state, and returns true when this press completed the emergency
|
||||
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
|
||||
* (mirrors the Linux client's escape chord: any one controller can leave).
|
||||
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||
*/
|
||||
fun onButton(event: KeyEvent, bit: Int): Boolean {
|
||||
val slot = slotFor(event.device) ?: return false
|
||||
fun onButton(event: KeyEvent, bit: Int) {
|
||||
val slot = slotFor(event.device) ?: return
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
|
||||
slot.held = 0
|
||||
return true
|
||||
}
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
val r = Runnable {
|
||||
pendingExit = null
|
||||
// Fire only if the chord survived the full hold on some pad.
|
||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||
if (held.isNotEmpty()) {
|
||||
// Release the held buttons + zero the axes on every triggering pad so nothing sticks
|
||||
// host-side once we leave, then signal the deliberate exit.
|
||||
for (s in held) releaseHeld(s)
|
||||
onExitChord?.invoke()
|
||||
}
|
||||
}
|
||||
pendingExit = r
|
||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||
}
|
||||
|
||||
/** Cancel a pending exit-chord hold timer. */
|
||||
private fun disarmExit() {
|
||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||
pendingExit = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
fun release() {
|
||||
inputManager?.unregisterInputDeviceListener(listener)
|
||||
disarmExit() // drop any pending exit-chord timer so it can't fire after teardown
|
||||
// Snapshot the ids first — closeSlot mutates the map.
|
||||
for (id in slots.keys.toList()) closeSlot(id)
|
||||
}
|
||||
@@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
onSlotClosed?.invoke(deviceId)
|
||||
}
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
@@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
|
||||
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,12 @@ public final class GamepadCapture {
|
||||
/// tagged with the slot's wire pad index.
|
||||
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
|
||||
guard !suspended else { return }
|
||||
let newButtons = Self.buttonMask(g)
|
||||
// guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out
|
||||
// of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
@@ -266,12 +271,12 @@ public final class GamepadCapture {
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
let newAxes: [Int32] = [
|
||||
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
|
||||
Int32((g.leftThumbstick.yAxis.value * 32767).rounded()),
|
||||
Int32((g.rightThumbstick.xAxis.value * 32767).rounded()),
|
||||
Int32((g.rightThumbstick.yAxis.value * 32767).rounded()),
|
||||
Int32((g.leftTrigger.value * 255).rounded()),
|
||||
Int32((g.rightTrigger.value * 255).rounded()),
|
||||
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||
Int32(g.leftThumbstick.yAxis.value * 32767),
|
||||
Int32(g.rightThumbstick.xAxis.value * 32767),
|
||||
Int32(g.rightThumbstick.yAxis.value * 32767),
|
||||
Int32(g.leftTrigger.value * 255),
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
@@ -300,11 +305,15 @@ public final class GamepadCapture {
|
||||
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
|
||||
if g.buttonMenu.isPressed { b |= GamepadWire.start }
|
||||
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
|
||||
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button —
|
||||
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
|
||||
// the create button BOTH as buttonOptions and as the share element this OR is harmless —
|
||||
// same wire bit.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
|
||||
// The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone
|
||||
// pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture
|
||||
// bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad
|
||||
// this is a button physically DISTINCT from View (buttonOptions, above), so it must not
|
||||
// collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam
|
||||
// quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and
|
||||
// this share element now emits back+misc1 for it — harmless on a plain xpad session (no
|
||||
// misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad.
|
||||
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 }
|
||||
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
|
||||
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
|
||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,9 @@ public final class GamepadMenuInput {
|
||||
let stick = gamepad.leftThumbstick
|
||||
let x = stick.xAxis.value
|
||||
let y = stick.yAxis.value
|
||||
if abs(x) > abs(y), abs(x) > deadzone {
|
||||
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
|
||||
// nav so a perfect 45° push resolves to the same direction on every client.
|
||||
if abs(x) >= abs(y), abs(x) > deadzone {
|
||||
return x > 0 ? .right : .left
|
||||
} else if abs(y) > deadzone {
|
||||
return y > 0 ? .up : .down
|
||||
|
||||
@@ -26,11 +26,27 @@ public enum GamepadWire {
|
||||
public static let y: UInt32 = 0x8000
|
||||
/// DualSense touchpad click (Moonlight's extended-button bit position).
|
||||
public static let touchpadClick: UInt32 = 0x10_0000
|
||||
/// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access
|
||||
/// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to
|
||||
/// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button.
|
||||
public static let misc1: UInt32 = 0x0020_0000
|
||||
/// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in
|
||||
/// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5).
|
||||
/// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not
|
||||
/// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence
|
||||
/// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so
|
||||
/// they are intentionally absent from `allButtons` until that forwarding lands.
|
||||
public static let paddle1: UInt32 = 0x0001_0000
|
||||
public static let paddle2: UInt32 = 0x0002_0000
|
||||
public static let paddle3: UInt32 = 0x0004_0000
|
||||
public static let paddle4: UInt32 = 0x0008_0000
|
||||
|
||||
/// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by
|
||||
/// `flush` on release. Paddles are excluded until their capture lands (see above).
|
||||
public static let allButtons: [UInt32] = [
|
||||
dpadUp, dpadDown, dpadLeft, dpadRight, start, back,
|
||||
leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide,
|
||||
a, b, x, y, touchpadClick,
|
||||
a, b, x, y, touchpadClick, misc1,
|
||||
]
|
||||
|
||||
public static let axisLSX: UInt32 = 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.x, 0x4000)
|
||||
XCTAssertEqual(GamepadWire.y, 0x8000)
|
||||
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
||||
XCTAssertEqual(GamepadWire.misc1, 0x0020_0000)
|
||||
// Every button is enumerated exactly once (releaseAll walks this list).
|
||||
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
||||
XCTAssertEqual(combined, 0x0010_F7FF)
|
||||
XCTAssertEqual(GamepadWire.allButtons.count, 16)
|
||||
XCTAssertEqual(combined, 0x0030_F7FF)
|
||||
XCTAssertEqual(GamepadWire.allButtons.count, 17)
|
||||
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
||||
// Paddles are defined but not yet forwarded, so they stay out of allButtons for now.
|
||||
for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] {
|
||||
XCTAssertFalse(GamepadWire.allButtons.contains(paddle))
|
||||
}
|
||||
// Axis ids.
|
||||
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
||||
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
||||
@@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRT, 5)
|
||||
}
|
||||
|
||||
func testButtonBitsMatchTheCABIVerbatim() {
|
||||
// Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same
|
||||
// source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the
|
||||
// Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review
|
||||
// finding G15: the button values were re-declared per client with only a 3-of-19 check.)
|
||||
XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP))
|
||||
XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN))
|
||||
XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT))
|
||||
XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT))
|
||||
XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START))
|
||||
XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK))
|
||||
XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK))
|
||||
XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK))
|
||||
XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB))
|
||||
XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB))
|
||||
XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE))
|
||||
XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A))
|
||||
XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B))
|
||||
XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X))
|
||||
XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y))
|
||||
XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD))
|
||||
XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1))
|
||||
XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1))
|
||||
XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2))
|
||||
XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3))
|
||||
XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4))
|
||||
// Axis ids and pad count share the same header.
|
||||
XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X))
|
||||
XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X))
|
||||
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
|
||||
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
|
||||
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
|
||||
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
|
||||
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
|
||||
|
||||
|
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,11 @@ 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"];
|
||||
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -320,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",
|
||||
],
|
||||
);
|
||||
@@ -420,12 +426,21 @@ pub fn show(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
let touch_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Touch input",
|
||||
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
|
||||
Direct pointer jumps to your finger; Touch passthrough sends real touches",
|
||||
TOUCH_MODE_LABELS,
|
||||
);
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
.build();
|
||||
input.add(forward_row.widget());
|
||||
input.add(pad_row.widget());
|
||||
input.add(touch_row.widget());
|
||||
input.add(&inhibit_row);
|
||||
|
||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||
@@ -488,6 +503,11 @@ pub fn show(
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||
pad_row.set_selected(pad_i as u32);
|
||||
let touch_i = TOUCH_MODES
|
||||
.iter()
|
||||
.position(|&t| t == s.touch_mode)
|
||||
.unwrap_or(0);
|
||||
touch_row.set_selected(touch_i as u32);
|
||||
let comp_i = COMPOSITORS
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -527,6 +547,8 @@ pub fn show(
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
|
||||
@@ -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(),
|
||||
@@ -140,9 +157,18 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
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),
|
||||
@@ -160,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(),
|
||||
@@ -183,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,
|
||||
|
||||
@@ -358,6 +358,7 @@ mod session_main {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
# decode + present live in the spawned punktfunk-session binary.)
|
||||
ffmpeg-next = "8"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
|
||||
# built from source via the bundled CMake on Windows (no system SDL3).
|
||||
sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
|
||||
|
||||
# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
|
||||
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
|
||||
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
|
||||
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
|
||||
mdns-sd = "0.20"
|
||||
async-channel = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -32,9 +32,9 @@ mod stream;
|
||||
mod style;
|
||||
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::gamepad::GamepadService;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use hosts::HostsProps;
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use speed::{SpeedProps, SpeedState};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -55,6 +55,13 @@ const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
(StatsVerbosity::Normal, "Normal"),
|
||||
(StatsVerbosity::Detailed, "Detailed"),
|
||||
];
|
||||
/// Touch-input presets: `(stored value, display label)` — how a touchscreen's fingers drive
|
||||
/// the host. The cross-client set (Android/Apple); only meaningful on a touchscreen device.
|
||||
const TOUCH_MODES: &[(&str, &str)] = &[
|
||||
("trackpad", "Trackpad"),
|
||||
("pointer", "Direct pointer"),
|
||||
("touch", "Touch passthrough"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -324,6 +331,14 @@ pub(crate) fn settings_page(
|
||||
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
|
||||
controller.",
|
||||
);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
@@ -405,6 +420,7 @@ pub(crate) fn settings_page(
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
touch_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` +
|
||||
//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform,
|
||||
//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled
|
||||
//! CMake, since there is no system SDL3).
|
||||
//!
|
||||
//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the
|
||||
//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while
|
||||
//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
|
||||
//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real
|
||||
//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs).
|
||||
//! Held state is zeroed on the wire when the active pad switches or the session detaches, so
|
||||
//! nothing sticks down.
|
||||
//!
|
||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived
|
||||
/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in
|
||||
/// rad/s and accel in m/s²; the DualSense report wants raw LSBs.
|
||||
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
|
||||
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||||
const G: f32 = 9.80665;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
/// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`)
|
||||
/// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the
|
||||
/// spawned session binary, whose own gamepad service applies the same key.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
|
||||
/// A short human label for the detected pad family, shown next to the name in the settings
|
||||
/// GUI's controller list ("" for a generic pad the name already describes).
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
Pin(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
||||
// WinUI app shares it across the UI thread and the settings-pin path.
|
||||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
pub fn start() -> GamepadService {
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let p = pads.clone();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &ctl_rx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
{
|
||||
tracing::warn!(error = %e, "gamepad service failed to start");
|
||||
}
|
||||
GamepadService {
|
||||
pads,
|
||||
ctl: Arc::new(Mutex::new(ctl)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connected controllers, most recently attached first (the settings GUI's list order).
|
||||
pub fn pads(&self) -> Vec<PadInfo> {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||
/// The pin survives the pad disconnecting: it re-applies the moment a matching
|
||||
/// controller shows up again (same semantics as `pf-client-core`'s service). The spawned
|
||||
/// `punktfunk-session` binary owns the actual forwarding; this persists the selection.
|
||||
pub fn set_pinned(&self, key: Option<String>) {
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key));
|
||||
}
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||||
let _ = connector.send_input(&InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y: 0,
|
||||
flags: 0, // pad index 0 — single-pad model
|
||||
});
|
||||
}
|
||||
|
||||
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
use sdl3::gamepad::Button;
|
||||
Some(match b {
|
||||
Button::South => wire::BTN_A,
|
||||
Button::East => wire::BTN_B,
|
||||
Button::West => wire::BTN_X,
|
||||
Button::North => wire::BTN_Y,
|
||||
Button::Back => wire::BTN_BACK,
|
||||
Button::Start => wire::BTN_START,
|
||||
Button::Guide => wire::BTN_GUIDE,
|
||||
Button::LeftStick => wire::BTN_LS_CLICK,
|
||||
Button::RightStick => wire::BTN_RS_CLICK,
|
||||
Button::LeftShoulder => wire::BTN_LB,
|
||||
Button::RightShoulder => wire::BTN_RB,
|
||||
Button::DPadUp => wire::BTN_DPAD_UP,
|
||||
Button::DPadDown => wire::BTN_DPAD_DOWN,
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
|
||||
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
|
||||
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
use sdl3::gamepad::Axis;
|
||||
match axis {
|
||||
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
|
||||
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
|
||||
Axis::RightX => (wire::AXIS_RS_X, v as i32),
|
||||
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
|
||||
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
|
||||
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
|
||||
}
|
||||
}
|
||||
|
||||
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host
|
||||
/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits
|
||||
/// select only the fields each update touches, so rumble (driven separately through SDL) and
|
||||
/// untouched fields keep their state.
|
||||
#[derive(Default)]
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
const RIGHT_TRIGGER: usize = 10;
|
||||
const LEFT_TRIGGER: usize = 21;
|
||||
const PAD_LIGHTS: usize = 43;
|
||||
const LED_RGB: usize = 44;
|
||||
|
||||
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
let (flag, off) = if which == 1 {
|
||||
(0x04, Self::RIGHT_TRIGGER)
|
||||
} else {
|
||||
(0x08, Self::LEFT_TRIGGER)
|
||||
};
|
||||
p[0] = flag;
|
||||
let n = effect.len().min(11);
|
||||
p[off..off + n].copy_from_slice(&effect[..n]);
|
||||
p
|
||||
}
|
||||
|
||||
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[1] = 0x04; // lightbar enable
|
||||
p[Self::LED_RGB] = r;
|
||||
p[Self::LED_RGB + 1] = g;
|
||||
p[Self::LED_RGB + 2] = b;
|
||||
p
|
||||
}
|
||||
|
||||
fn player_packet(bits: u8) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[1] = 0x10; // player-LED enable
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
/// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup
|
||||
/// so it re-applies whenever a matching pad (re)connects.
|
||||
pinned: Option<String>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
self.pinned
|
||||
.as_deref()
|
||||
.and_then(|key| {
|
||||
self.order
|
||||
.iter()
|
||||
.rev() // prefer the most recently connected pad with this identity
|
||||
.find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key))
|
||||
.copied()
|
||||
})
|
||||
.or_else(|| self.order.last().copied())
|
||||
}
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0));
|
||||
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
let name = pad.name().unwrap_or_else(|| "Controller".into());
|
||||
Some(PadInfo {
|
||||
// Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted
|
||||
// `forward_pad` is applied by BOTH services (this one and the session's).
|
||||
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||||
name,
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
/// Zero everything the host believes is held — on pad switch and detach.
|
||||
fn flush_held(&mut self) {
|
||||
if let Some(c) = &self.attached {
|
||||
for b in self.held_buttons.drain(..) {
|
||||
send(c, InputKind::GamepadButton, b, 0);
|
||||
}
|
||||
for (id, v) in self.last_axis.iter_mut().enumerate() {
|
||||
if *v != 0 && *v != i32::MIN {
|
||||
send(c, InputKind::GamepadAxis, id as u32, 0);
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||
fn set_sensors(&mut self, enabled: bool) {
|
||||
let Some(id) = self.active_id() else { return };
|
||||
if let Some(pad) = self.opened.get_mut(&id) {
|
||||
use sdl3::sensor::SensorType;
|
||||
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
|
||||
if unsafe { pad.has_sensor(s) } {
|
||||
let _ = pad.sensor_set_enabled(s, enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run(pads_out: &Mutex<Vec<PadInfo>>, ctl: &Receiver<Ctl>) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
|
||||
// thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut w = Worker {
|
||||
subsystem,
|
||||
opened: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
};
|
||||
|
||||
let publish = |w: &Worker| {
|
||||
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*pads_out.lock().unwrap() = list;
|
||||
};
|
||||
|
||||
loop {
|
||||
// Control plane from the UI thread.
|
||||
loop {
|
||||
match ctl.try_recv() {
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = w.active_id();
|
||||
w.pinned = key;
|
||||
if w.active_id() != before {
|
||||
w.flush_held();
|
||||
if w.attached.is_some() {
|
||||
w.set_sensors(true);
|
||||
}
|
||||
}
|
||||
publish(&w);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(event) = pump.poll_event() {
|
||||
use sdl3::event::Event;
|
||||
let active = w.active_id();
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !w.opened.contains_key(&which) {
|
||||
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
|
||||
Ok(pad) => {
|
||||
tracing::info!(
|
||||
name = pad.name().unwrap_or_default(),
|
||||
"gamepad attached"
|
||||
);
|
||||
w.opened.insert(which, pad);
|
||||
w.order.push(which);
|
||||
if w.attached.is_some() && w.active_id() == Some(which) {
|
||||
w.set_sensors(true);
|
||||
}
|
||||
publish(&w);
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ControllerDeviceRemoved { which, .. } => {
|
||||
if w.opened.remove(&which).is_some() {
|
||||
w.order.retain(|&id| id != which);
|
||||
if active == Some(which) {
|
||||
w.flush_held();
|
||||
}
|
||||
tracing::info!("gamepad detached");
|
||||
publish(&w);
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. }
|
||||
if active == Some(which) && w.attached.is_some() =>
|
||||
{
|
||||
if let Some(bit) = button_bit(button) {
|
||||
w.held_buttons.push(bit);
|
||||
send(
|
||||
w.attached.as_ref().unwrap(),
|
||||
InputKind::GamepadButton,
|
||||
bit,
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonUp { which, button, .. }
|
||||
if active == Some(which) && w.attached.is_some() =>
|
||||
{
|
||||
if let Some(bit) = button_bit(button) {
|
||||
w.held_buttons.retain(|&b| b != bit);
|
||||
send(
|
||||
w.attached.as_ref().unwrap(),
|
||||
InputKind::GamepadButton,
|
||||
bit,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::ControllerAxisMotion {
|
||||
which, axis, value, ..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let (id, v) = axis_value(axis, value);
|
||||
if w.last_axis[id as usize] != v {
|
||||
w.last_axis[id as usize] = v;
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
||||
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||
// clients — sign/scale derived, not yet live-verified.
|
||||
Event::ControllerSensorUpdated {
|
||||
which,
|
||||
sensor,
|
||||
data,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
use sdl3::sensor::SensorType;
|
||||
match sensor {
|
||||
SensorType::Accelerometer => {
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
w.last_accel[i] =
|
||||
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
}
|
||||
SensorType::Gyroscope => {
|
||||
let mut gyro = [0i16; 3];
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
let _ =
|
||||
w.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro,
|
||||
accel: w.last_accel,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback planes (this thread is their single consumer). Rumble arrives as
|
||||
// self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
|
||||
// lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
|
||||
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
|
||||
// proven 5 s duration and rely on its periodic re-send as before.
|
||||
if let Some(connector) = w.attached.clone() {
|
||||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
// Floor the lease so a jittered renewal can't gap the actuator between writes.
|
||||
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
|
||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, dur_ms) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
match hid {
|
||||
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||
}
|
||||
HidOutput::Led { pad: 0, r, g, b } => {
|
||||
let _ = pad.set_led(r, g, b);
|
||||
}
|
||||
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which,
|
||||
ref effect,
|
||||
} if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
|
||||
2
|
||||
} else {
|
||||
30
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,6 @@ mod app;
|
||||
#[cfg(windows)]
|
||||
mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gamepad;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod probe;
|
||||
@@ -85,7 +83,11 @@ fn main() {
|
||||
tracing::error!(error = %e, "Windows App SDK bootstrap failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let gamepad = gamepad::GamepadService::start();
|
||||
// The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings
|
||||
// list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the
|
||||
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
|
||||
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
|
||||
let gamepad = pf_client_core::gamepad::GamepadService::start();
|
||||
if let Err(e) = app::run(identity, gamepad) {
|
||||
tracing::error!(error = %e, "WinUI app failed");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -71,6 +71,15 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
/// left untouched.
|
||||
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
||||
|
||||
/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host
|
||||
/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh,
|
||||
/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every
|
||||
/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely)
|
||||
/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s,
|
||||
/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its
|
||||
/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]).
|
||||
const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000;
|
||||
|
||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||
const MENU_DEADZONE: u16 = 16384;
|
||||
@@ -255,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",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -288,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,
|
||||
}
|
||||
}
|
||||
@@ -558,9 +573,9 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
use sdl3::gamepad::Axis;
|
||||
match axis {
|
||||
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
|
||||
Axis::LeftX => (wire::AXIS_LS_X, (v as i32).max(-32767)),
|
||||
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
|
||||
Axis::RightX => (wire::AXIS_RS_X, v as i32),
|
||||
Axis::RightX => (wire::AXIS_RS_X, (v as i32).max(-32767)),
|
||||
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
|
||||
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
|
||||
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
|
||||
@@ -617,6 +632,12 @@ struct RumbleState {
|
||||
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
||||
last: (u16, u16),
|
||||
last_at: Option<Instant>,
|
||||
/// When the last *real* host rumble datagram landed on this slot — set only in the feedback
|
||||
/// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive
|
||||
/// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what
|
||||
/// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive
|
||||
/// stops and issues one (0, 0). See [`Worker::render_feedback`].
|
||||
updated_at: Option<Instant>,
|
||||
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
|
||||
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
|
||||
jitter: bool,
|
||||
@@ -763,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)
|
||||
@@ -1492,6 +1522,10 @@ impl Worker {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive
|
||||
// re-kick refreshes every ~40 ms), this clock advances only here, so a legacy
|
||||
// lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below.
|
||||
slot.rumble.updated_at = Some(Instant::now());
|
||||
Self::issue_rumble(slot, low, high, deck);
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1545,17 @@ impl Worker {
|
||||
slot.rumble.deadline = None;
|
||||
slot.rumble.ttl_ms = 0;
|
||||
Self::issue_rumble(slot, 0, 0, true);
|
||||
} else if slot.rumble.ttl_ms == 0
|
||||
&& slot
|
||||
.rumble
|
||||
.updated_at
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS))
|
||||
{
|
||||
// Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so
|
||||
// this only trips on a lost stop-frame the host never followed up — silence the
|
||||
// actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble`
|
||||
// sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks.
|
||||
Self::issue_rumble(slot, 0, 0, true);
|
||||
} else if slot
|
||||
.rumble
|
||||
.last_at
|
||||
@@ -1526,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));
|
||||
|
||||
@@ -343,6 +343,53 @@ impl StatsVerbosity {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a touchscreen's fingers drive the host — the cross-client touch-input model (Android
|
||||
/// `TouchMode`, Apple `TouchInputMode`). Stored stringly in [`Settings::touch_mode`] so the
|
||||
/// file stays readable; parsed with [`TouchMode::from_name`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TouchMode {
|
||||
/// Relative cursor like a laptop touchpad: the cursor stays put on touch-down and moves
|
||||
/// by the finger's delta (with mild acceleration), tap to click. The default — a cursor
|
||||
/// is the universally workable model on a screen the host isn't sized for.
|
||||
Trackpad,
|
||||
/// Direct pointing: the cursor jumps to the finger and follows it (absolute).
|
||||
Pointer,
|
||||
/// Real multi-touch passthrough: every finger is a host touchscreen contact, no gesture
|
||||
/// interpretation — only helps hosts/apps that actually understand touch.
|
||||
Touch,
|
||||
}
|
||||
|
||||
impl TouchMode {
|
||||
/// Cycle/picker order (also the settings pickers' option order).
|
||||
pub const ALL: [TouchMode; 3] = [TouchMode::Trackpad, TouchMode::Pointer, TouchMode::Touch];
|
||||
|
||||
/// Parse the persisted name, defaulting to `Trackpad` for unset/unknown values.
|
||||
pub fn from_name(s: &str) -> TouchMode {
|
||||
match s {
|
||||
"pointer" => TouchMode::Pointer,
|
||||
"touch" => TouchMode::Touch,
|
||||
_ => TouchMode::Trackpad,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
||||
pub fn as_name(self) -> &'static str {
|
||||
match self {
|
||||
TouchMode::Trackpad => "trackpad",
|
||||
TouchMode::Pointer => "pointer",
|
||||
TouchMode::Touch => "touch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TouchMode::Trackpad => "Trackpad",
|
||||
TouchMode::Pointer => "Direct pointer",
|
||||
TouchMode::Touch => "Touch passthrough",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -363,6 +410,12 @@ pub struct Settings {
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
/// How a touchscreen's fingers drive the host (Deck/tablet): a [`TouchMode`] name —
|
||||
/// `"trackpad"` (default), `"pointer"`, or `"touch"`. Read at connect via
|
||||
/// [`Settings::touch_mode`]; irrelevant on a mouse-only client. `default` so pre-existing
|
||||
/// stores load as trackpad.
|
||||
#[serde(default = "default_touch_mode")]
|
||||
pub touch_mode: String,
|
||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
@@ -425,6 +478,10 @@ fn default_codec() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -447,6 +504,11 @@ impl Settings {
|
||||
self.show_stats = v != StatsVerbosity::Off;
|
||||
}
|
||||
|
||||
/// The touch-input model for this session (parsed from the stored name).
|
||||
pub fn touch_mode(&self) -> TouchMode {
|
||||
TouchMode::from_name(&self.touch_mode)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -468,6 +530,7 @@ impl Default for Settings {
|
||||
gamepad: "auto".into(),
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
@@ -519,6 +582,23 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
|
||||
/// default), and the name round-trips through the enum both ways.
|
||||
#[test]
|
||||
fn settings_touch_mode_defaults_trackpad() {
|
||||
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.touch_mode, "trackpad");
|
||||
assert_eq!(s.touch_mode(), TouchMode::Trackpad);
|
||||
// Explicit values parse; an unknown name falls back to trackpad.
|
||||
assert_eq!(TouchMode::from_name("pointer"), TouchMode::Pointer);
|
||||
assert_eq!(TouchMode::from_name("touch"), TouchMode::Touch);
|
||||
assert_eq!(TouchMode::from_name("bogus"), TouchMode::Trackpad);
|
||||
for m in TouchMode::ALL {
|
||||
assert_eq!(TouchMode::from_name(m.as_name()), m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -16,11 +16,21 @@
|
||||
//! otherwise send a datagram per event).
|
||||
|
||||
use crate::keymap_sdl;
|
||||
use crate::touch::{Abs, Act, Gestures};
|
||||
use pf_client_core::trust::TouchMode;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Which transition a forwarded touchscreen finger is (SDL delivers one finger per event).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FingerPhase {
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
}
|
||||
|
||||
pub struct Capture {
|
||||
connector: Arc<NativeClient>,
|
||||
captured: bool,
|
||||
@@ -34,6 +44,16 @@ pub struct Capture {
|
||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
||||
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||
scroll_acc: (f64, f64),
|
||||
/// Active touchscreen contacts: SDL finger id → the small wire touch id (slot) we
|
||||
/// forward it under. SDL finger ids are opaque and large; the host wants compact,
|
||||
/// per-contact-unique ids reusable after up (input.rs::TouchDown). Slots are freed on
|
||||
/// up and flushed up on release so no contact stays pressed on the host. Only used in
|
||||
/// [`TouchMode::Touch`]; the other modes drive `gestures` instead.
|
||||
touch_slots: HashMap<u64, u32>,
|
||||
/// The touchscreen input model for this session, and — for trackpad/pointer — the
|
||||
/// gesture state machine finger events feed.
|
||||
touch_mode: TouchMode,
|
||||
gestures: Gestures,
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
@@ -48,7 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(connector: Arc<NativeClient>) -> Capture {
|
||||
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
captured: false,
|
||||
@@ -57,6 +77,9 @@ impl Capture {
|
||||
held_buttons: HashSet::new(),
|
||||
pending_rel: (0, 0),
|
||||
scroll_acc: (0.0, 0.0),
|
||||
touch_slots: HashMap::new(),
|
||||
touch_mode,
|
||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +116,12 @@ impl Capture {
|
||||
for b in self.held_buttons.drain() {
|
||||
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
}
|
||||
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
}
|
||||
// The gesture engine's held left button (a tap-drag in progress) rides in
|
||||
// `held_buttons` above, so it was just flushed — here we only forget its state.
|
||||
self.gestures.reset();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -180,4 +209,136 @@ impl Capture {
|
||||
}
|
||||
self.scroll_acc = (ax, ay);
|
||||
}
|
||||
|
||||
/// The compact wire touch id for an SDL finger — its existing slot, or the lowest free
|
||||
/// one (contacts are few, so a linear scan is nothing). Held until the finger lifts.
|
||||
fn touch_slot(&mut self, finger_id: u64) -> u32 {
|
||||
if let Some(&slot) = self.touch_slots.get(&finger_id) {
|
||||
return slot;
|
||||
}
|
||||
let used: HashSet<u32> = self.touch_slots.values().copied().collect();
|
||||
let slot = (0u32..).find(|s| !used.contains(s)).unwrap_or(0);
|
||||
self.touch_slots.insert(finger_id, slot);
|
||||
slot
|
||||
}
|
||||
|
||||
/// Touch flags pack the client surface size the coordinates are relative to, so the
|
||||
/// host can rescale into its output — identical layout to Android's nativeSendTouch.
|
||||
fn touch_flags(w: u32, h: u32) -> u32 {
|
||||
((w & 0xffff) << 16) | (h & 0xffff)
|
||||
}
|
||||
|
||||
/// A new touchscreen contact — `x`/`y` are absolute in the `w`×`h` content surface.
|
||||
/// Ignored unless captured (the stream owns the glass; the menu is gamepad-driven).
|
||||
pub fn on_touch_down(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
let slot = self.touch_slot(finger_id);
|
||||
send(
|
||||
&self.connector,
|
||||
InputKind::TouchDown,
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
Self::touch_flags(w, h),
|
||||
);
|
||||
}
|
||||
|
||||
/// A contact moved. Only forwarded for a finger we already sent a down for — a move
|
||||
/// with no live slot (capture engaged mid-touch) would have no matching host contact.
|
||||
pub fn on_touch_move(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
if let Some(&slot) = self.touch_slots.get(&finger_id) {
|
||||
send(
|
||||
&self.connector,
|
||||
InputKind::TouchMove,
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
Self::touch_flags(w, h),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A contact lifted — release its slot and the host contact. Forwarded even when not
|
||||
/// captured: a `release()` may have already flushed it (then the slot is gone and this
|
||||
/// no-ops), but a stray up must never strand a pressed contact on the host.
|
||||
pub fn on_touch_up(&mut self, finger_id: u64) {
|
||||
if let Some(slot) = self.touch_slots.remove(&finger_id) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one forwarded touchscreen finger by the session's touch model. `wx`/`wy` are
|
||||
/// physical window pixels (the trackpad ballistics + gesture geometry); `abs` is the same
|
||||
/// finger mapped into the letterboxed content rect (pointer moves + raw passthrough). In
|
||||
/// `Touch` mode fingers go on the wire as real contacts; in `Trackpad`/`Pointer` they
|
||||
/// drive the gesture engine. Returns true when a three-finger tap asks to cycle the stats
|
||||
/// overlay — the only signal the run loop must act on.
|
||||
pub fn dispatch_finger(
|
||||
&mut self,
|
||||
phase: FingerPhase,
|
||||
id: u64,
|
||||
wx: f32,
|
||||
wy: f32,
|
||||
abs: Abs,
|
||||
t_ms: f64,
|
||||
) -> bool {
|
||||
match self.touch_mode {
|
||||
TouchMode::Touch => {
|
||||
match phase {
|
||||
FingerPhase::Down => self.on_touch_down(id, abs.x, abs.y, abs.w, abs.h),
|
||||
FingerPhase::Move => self.on_touch_move(id, abs.x, abs.y, abs.w, abs.h),
|
||||
FingerPhase::Up => self.on_touch_up(id),
|
||||
}
|
||||
false
|
||||
}
|
||||
TouchMode::Trackpad | TouchMode::Pointer => {
|
||||
// Down/Move only while captured (the stream owns the glass); an Up always runs
|
||||
// so a lift can conclude a gesture / release a held drag even if capture just
|
||||
// dropped (focus loss mid-touch).
|
||||
if !self.captured && phase != FingerPhase::Up {
|
||||
return false;
|
||||
}
|
||||
let acts = match phase {
|
||||
FingerPhase::Down => self.gestures.down(id, wx, wy, abs, t_ms),
|
||||
FingerPhase::Move => self.gestures.motion(id, wx, wy, abs, t_ms),
|
||||
FingerPhase::Up => self.gestures.up(id, t_ms),
|
||||
};
|
||||
let mut cycle_stats = false;
|
||||
for act in acts {
|
||||
cycle_stats |= self.apply_touch_act(act);
|
||||
}
|
||||
cycle_stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one gesture [`Act`] on the wire, tracking button holds in `held_buttons` so a
|
||||
/// capture release flushes them (a tap-drag's left button never sticks down). Returns
|
||||
/// true for [`Act::CycleStats`], which is a run-loop signal, not a wire event.
|
||||
fn apply_touch_act(&mut self, act: Act) -> bool {
|
||||
match act {
|
||||
Act::CycleStats => return true,
|
||||
Act::Button { gs, down } => {
|
||||
if down {
|
||||
self.flush_motion(); // the press lands where the cursor now is
|
||||
self.held_buttons.insert(gs);
|
||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
} else if self.held_buttons.remove(&gs) {
|
||||
self.flush_motion();
|
||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some((kind, code, x, y, flags)) = other.wire() {
|
||||
send(&self.connector, kind, code, x, y, flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub mod overlay;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod touch;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod vk;
|
||||
#[cfg(windows)]
|
||||
mod win32;
|
||||
|
||||
@@ -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,13 +13,14 @@
|
||||
//! the stdout line always carries the full Detailed text so parsers see a stable
|
||||
//! shape). Logs go to stderr (the binary configures tracing so).
|
||||
|
||||
use crate::input::Capture;
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -43,6 +44,10 @@ pub struct SessionOpts {
|
||||
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
|
||||
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
|
||||
pub stats_verbosity: StatsVerbosity,
|
||||
/// Touchscreen input model (Deck/tablet): `Trackpad` (relative cursor + gestures),
|
||||
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
||||
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
||||
pub touch_mode: TouchMode,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -204,6 +209,11 @@ struct StreamState {
|
||||
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
|
||||
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
|
||||
resize_overlay: ResizeIndicator,
|
||||
/// The last presented frame's video dimensions — the source rect touch passthrough
|
||||
/// maps a finger into (the video is letterboxed within the window, so a finger's
|
||||
/// window-normalized position must be re-based onto the content rect). `None` until
|
||||
/// the first frame; touches before then have nothing to map onto and are dropped.
|
||||
last_video: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -253,6 +263,7 @@ impl StreamState {
|
||||
resize_requested: None,
|
||||
shown_mode: None,
|
||||
resize_overlay: ResizeIndicator::default(),
|
||||
last_video: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +310,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
// mouse lock becomes a large positive delta that walks the host cursor into the
|
||||
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
||||
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
@@ -512,24 +530,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::S {
|
||||
stats_verbosity = stats_verbosity.next();
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
|
||||
// Re-render the OSD from the last window immediately — waiting
|
||||
// for the next Stats event would lag the keypress by up to 1 s.
|
||||
if let Some(st) = &mut stream {
|
||||
let text = match &st.last_stats {
|
||||
Some(s) => stats_text(
|
||||
stats_verbosity,
|
||||
&st.mode_line,
|
||||
s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
st.osd_text = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||
@@ -581,6 +583,78 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
cap.on_wheel(x, y);
|
||||
}
|
||||
}
|
||||
// Touchscreen fingers (the Deck's glass) → the session's touch model
|
||||
// (Trackpad/Pointer mouse, or real Touch passthrough), routed by `Capture`.
|
||||
// `x`/`y` are window-normalized (0..1); the dispatcher gets physical window
|
||||
// pixels AND the letterbox mapping. Only DIRECT devices (touchscreens) — an
|
||||
// INDIRECT trackpad drives the mouse and must not be mistaken for one. A
|
||||
// three-finger tap returns `cycle` → bump the stats tier, same as Ctrl+⌥+⇧+S.
|
||||
Event::FingerDown {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Down,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
Event::FingerMotion {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Move,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
Event::FingerUp {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Up,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
// The wake forwarder's FrameWake (and any other user event): pure
|
||||
// wake-up — the frame drain below runs this iteration either way.
|
||||
Event::User { .. } => {}
|
||||
@@ -713,7 +787,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.ok();
|
||||
gamepad.attach(c.clone());
|
||||
st.clock_offset = Some(c.clock_offset_shared());
|
||||
let mut cap = Capture::new(c.clone());
|
||||
let mut cap = Capture::new(c.clone(), opts.touch_mode);
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
st.capture = Some(cap);
|
||||
@@ -887,6 +961,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
st.resize_overlay.decoded(fw, fh);
|
||||
st.last_video = Some((fw, fh)); // touch passthrough's source rect
|
||||
let DecodedFrame {
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
@@ -1291,6 +1366,111 @@ fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUti
|
||||
window.set_keyboard_grab(on);
|
||||
}
|
||||
|
||||
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
|
||||
/// Trackpads report INDIRECT and drive the mouse — their finger events must not be
|
||||
/// forwarded as touch passthrough. An unknown/invalid id (INVALID) reads as not-direct.
|
||||
fn is_direct_touch(touch_id: u64) -> bool {
|
||||
use sdl3::sys::touch::{SDL_GetTouchDeviceType, SDL_TouchDeviceType, SDL_TouchID};
|
||||
unsafe { SDL_GetTouchDeviceType(SDL_TouchID(touch_id)) == SDL_TouchDeviceType::DIRECT }
|
||||
}
|
||||
|
||||
/// Route one SDL touchscreen finger into the active session's [`Capture`] per the touch
|
||||
/// model. SDL delivers window-normalized `x`/`y` (0..1) and a nanosecond `timestamp`; the
|
||||
/// dispatcher hands `Capture` physical window pixels (trackpad ballistics + gesture geometry)
|
||||
/// AND the finger mapped into the letterboxed content rect (pointer moves + raw passthrough).
|
||||
/// Returns whether a three-finger tap asked to cycle the stats tier. Down/Move before the
|
||||
/// first decoded frame have nothing to map onto and are dropped; an Up always dispatches so a
|
||||
/// lift can release a held contact/drag.
|
||||
fn dispatch_finger(
|
||||
phase: FingerPhase,
|
||||
window: &sdl3::video::Window,
|
||||
stream: &mut Option<StreamState>,
|
||||
finger_id: u64,
|
||||
x: f32,
|
||||
y: f32,
|
||||
timestamp: u64,
|
||||
) -> bool {
|
||||
let Some(st) = stream.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let (wx, wy) = (x * pw as f32, y * ph as f32);
|
||||
let abs = match st.last_video {
|
||||
Some(video) => {
|
||||
let (ax, ay, aw, ah) = finger_to_content((pw, ph), video, x, y);
|
||||
Abs {
|
||||
x: ax,
|
||||
y: ay,
|
||||
w: aw,
|
||||
h: ah,
|
||||
}
|
||||
}
|
||||
None if phase == FingerPhase::Up => Abs {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
},
|
||||
None => return false,
|
||||
};
|
||||
let Some(cap) = st.capture.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
cap.dispatch_finger(
|
||||
phase,
|
||||
finger_id,
|
||||
wx,
|
||||
wy,
|
||||
abs,
|
||||
timestamp as f64 / 1_000_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Advance the stats-overlay tier and re-render the OSD immediately from the last window
|
||||
/// (waiting for the next Stats event would lag the trigger by up to 1 s). Shared by the
|
||||
/// Ctrl+Alt+Shift+S chord and the three-finger touch tap.
|
||||
fn bump_stats_tier(
|
||||
verbosity: &mut StatsVerbosity,
|
||||
stream: &mut Option<StreamState>,
|
||||
presenter: &Presenter,
|
||||
) {
|
||||
*verbosity = verbosity.next();
|
||||
if let Some(st) = stream {
|
||||
st.osd_text = match &st.last_stats {
|
||||
Some(s) => stats_text(
|
||||
*verbosity,
|
||||
&st.mode_line,
|
||||
s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure Contain-fit mapping (window pixels in, content pixels out) — split out so the
|
||||
/// letterbox math is testable without a live SDL window. Mirrors
|
||||
/// [`vk::letterbox`]; a finger in the letterbox bars clamps to the nearest content edge.
|
||||
fn finger_to_content(
|
||||
surface: (u32, u32),
|
||||
video: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> (i32, i32, u32, u32) {
|
||||
let (pw, ph) = (f64::from(surface.0), f64::from(surface.1));
|
||||
let (vw, vh) = video;
|
||||
let scale = (pw / f64::from(vw.max(1))).min(ph / f64::from(vh.max(1)));
|
||||
let dw = (f64::from(vw) * scale).max(1.0);
|
||||
let dh = (f64::from(vh) * scale).max(1.0);
|
||||
let ox = (pw - dw) / 2.0;
|
||||
let oy = (ph - dh) / 2.0;
|
||||
let cx = ((f64::from(x) * pw - ox) / dw).clamp(0.0, 1.0) * dw;
|
||||
let cy = ((f64::from(y) * ph - oy) / dh).clamp(0.0, 1.0) * dh;
|
||||
(cx.round() as i32, cy.round() as i32, dw as u32, dh as u32)
|
||||
}
|
||||
|
||||
/// The presenter's share of the unified stats window — folded into each printed line.
|
||||
#[derive(Default)]
|
||||
struct PresentedWindow {
|
||||
@@ -1614,4 +1794,46 @@ mod tests {
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_maps_across_a_perfectly_filled_surface() {
|
||||
// Video exactly fills the window (no letterbox): normalized finger → content
|
||||
// corners/center map straight through, and the surface size is the video size.
|
||||
let video = (1920, 1080);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 0.0, 0.0),
|
||||
(0, 0, 1920, 1080)
|
||||
);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 1.0, 1.0),
|
||||
(1920, 1080, 1920, 1080)
|
||||
);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 0.5, 0.5),
|
||||
(960, 540, 1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_rebases_onto_the_letterboxed_content_rect() {
|
||||
// 16:9 video in the Deck's 16:10 glass (1280×800) letterboxes: content is
|
||||
// 1280×720, centered with 40px bars top/bottom. A finger at the window's vertical
|
||||
// center is the content's vertical center; a finger inside the top bar clamps to
|
||||
// the content's top edge (not a negative coordinate).
|
||||
let surface = (1280, 800);
|
||||
let video = (1920, 1080);
|
||||
let (_, cy, w, h) = finger_to_content(surface, video, 0.5, 0.5);
|
||||
assert_eq!((w, h), (1280, 720));
|
||||
assert_eq!(cy, 360);
|
||||
// y=0.01 → window pixel 8, above the 40px bar → clamps to content top (0).
|
||||
assert_eq!(
|
||||
finger_to_content(surface, video, 0.5, 0.01),
|
||||
(640, 0, 1280, 720)
|
||||
);
|
||||
// Bottom-right corner of the video content.
|
||||
assert_eq!(
|
||||
finger_to_content(surface, video, 1.0, 1.0),
|
||||
(1280, 720, 1280, 720)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Touchscreen fingers → host mouse for the `trackpad`/`pointer` touch-input models — an
|
||||
//! incremental port of the Android client's gesture engine (clients/android
|
||||
//! `TouchInput.kt`) and its Apple twin (`TouchMouse.swift`) so all three touch clients feel
|
||||
//! identical. The third model, `touch`, never reaches here: those fingers go on the wire as
|
||||
//! real multi-touch contacts (`Capture::on_touch_*`).
|
||||
//!
|
||||
//! Two mouse models share one gesture vocabulary:
|
||||
//! * **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
|
||||
//! across, tap to click where it is. What makes a cursor reachable on a small screen.
|
||||
//! * **pointer**: the cursor jumps to the finger and follows it (absolute moves through the
|
||||
//! aspect-fit letterbox) — direct pointing.
|
||||
//!
|
||||
//! 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. (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
|
||||
//! incremental state machine: it keeps every live finger's position and recomputes the
|
||||
//! centroid itself. Positions are in physical window pixels (the caller multiplies SDL's
|
||||
//! normalized 0..1 finger coordinates by the window's pixel size) so the pixel-based
|
||||
//! ballistics constants port from Android 1:1; timestamps are milliseconds.
|
||||
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Gesture/ballistics tuning (physical px / ms), matching the Android reference exactly.
|
||||
/// Movement under this (px) still counts as a tap, not a drag.
|
||||
const TAP_SLOP: f32 = 12.0;
|
||||
/// A new touch this soon (ms) after a tap, near it, starts a held left-button drag.
|
||||
const TAP_DRAG_MS: f64 = 250.0;
|
||||
/// Two-finger pan distance (px) per 120-unit wheel notch (smaller = faster scroll).
|
||||
const SCROLL_DIV: f32 = 4.0;
|
||||
/// Base finger-px → host-px gain (~1:1, never twitchy).
|
||||
const POINTER_SENS: f32 = 1.3;
|
||||
/// Above `ACCEL_SPEED_FLOOR` px/ms the gain ramps by `ACCEL_GAIN` per px/ms, capped at
|
||||
/// `ACCEL_MAX` so a fast swipe can't fling the cursor uncontrollably.
|
||||
const ACCEL_GAIN: f32 = 0.6;
|
||||
const ACCEL_SPEED_FLOOR: f32 = 0.3;
|
||||
const ACCEL_MAX: f32 = 3.0;
|
||||
|
||||
/// GameStream mouse button ids.
|
||||
const BTN_LEFT: u32 = 1;
|
||||
const BTN_RIGHT: u32 = 3;
|
||||
|
||||
/// A finger's position in the letterboxed video content rect (absolute host pixels + the
|
||||
/// content surface size) — what `pointer` mode's absolute moves carry. Mirrors the
|
||||
/// `MouseMoveAbs` packing the host rescales into its output.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Abs {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
/// A wire intent the engine emits; the owner ([`Capture`](crate::input::Capture)) translates
|
||||
/// each into an actual `send_input`, and folds [`CycleStats`](Act::CycleStats) back to the
|
||||
/// run loop.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Act {
|
||||
/// Relative cursor motion (`MouseMove`).
|
||||
MoveRel { dx: i32, dy: i32 },
|
||||
/// Absolute cursor position through the letterbox (`MouseMoveAbs`).
|
||||
MoveAbs(Abs),
|
||||
/// A mouse button transition (`gs` = GameStream id; `down` = press/release).
|
||||
Button { gs: u32, down: bool },
|
||||
/// A wheel step: `axis` 0 = vertical, 1 = horizontal; `delta` in WHEEL(120) units.
|
||||
Scroll { axis: u32, delta: i32 },
|
||||
/// Three-finger tap: cycle the stats-overlay verbosity tier (the run loop owns it).
|
||||
CycleStats,
|
||||
}
|
||||
|
||||
impl Act {
|
||||
/// The `(InputKind, code, x, y, flags)` this intent sends. `Button`/`CycleStats` don't map
|
||||
/// to a single motion send, so callers special-case them; this covers the motion/scroll
|
||||
/// intents shared with the raw pointer path.
|
||||
pub fn wire(self) -> Option<(InputKind, u32, i32, i32, u32)> {
|
||||
match self {
|
||||
Act::MoveRel { dx, dy } => Some((InputKind::MouseMove, 0, dx, dy, 0)),
|
||||
Act::MoveAbs(a) => Some((
|
||||
InputKind::MouseMoveAbs,
|
||||
0,
|
||||
a.x,
|
||||
a.y,
|
||||
((a.w & 0xffff) << 16) | (a.h & 0xffff),
|
||||
)),
|
||||
Act::Scroll { axis, delta } => Some((InputKind::MouseScroll, axis, delta, 0, 0)),
|
||||
Act::Button { .. } | Act::CycleStats => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trackpad/pointer gesture state machine. One per session; `trackpad` picks the model
|
||||
/// (false = pointer). Fed only DIRECT touchscreen fingers.
|
||||
pub struct Gestures {
|
||||
trackpad: bool,
|
||||
/// Live fingers → current window-pixel position (the centroid needs every finger, but a
|
||||
/// move event only carries the one that changed).
|
||||
positions: HashMap<u64, (f32, f32)>,
|
||||
/// A gesture is in flight (≥ 1 finger down since the first touch).
|
||||
active: bool,
|
||||
start: (f32, f32),
|
||||
max_fingers: usize,
|
||||
moved: bool,
|
||||
scrolling: bool,
|
||||
/// Finger count the scroll centroid is anchored at — re-anchor on a count change so a
|
||||
/// 2→3 transition isn't read as a scroll notch.
|
||||
scroll_count: usize,
|
||||
scroll_anchor: (f32, f32),
|
||||
/// A tap-then-press-and-drag is holding the left button down for this whole gesture.
|
||||
drag_held: bool,
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
|
||||
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
|
||||
track_id: Option<u64>,
|
||||
prev: (f32, f32),
|
||||
prev_t: f64,
|
||||
carry: (f32, f32),
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
last_tap_up: f64,
|
||||
last_tap_pt: (f32, f32),
|
||||
}
|
||||
|
||||
impl Gestures {
|
||||
pub fn new(trackpad: bool) -> Gestures {
|
||||
Gestures {
|
||||
trackpad,
|
||||
positions: HashMap::new(),
|
||||
active: false,
|
||||
start: (0.0, 0.0),
|
||||
max_fingers: 0,
|
||||
moved: false,
|
||||
scrolling: false,
|
||||
scroll_count: 0,
|
||||
scroll_anchor: (0.0, 0.0),
|
||||
drag_held: false,
|
||||
track_id: None,
|
||||
prev: (0.0, 0.0),
|
||||
prev_t: 0.0,
|
||||
carry: (0.0, 0.0),
|
||||
last_tap_up: 0.0,
|
||||
last_tap_pt: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger touched down. `abs` is its letterbox mapping (pointer mode jumps the cursor
|
||||
/// there on the first finger). `t` is milliseconds.
|
||||
pub fn down(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
let first = self.positions.is_empty() && !self.active;
|
||||
self.positions.insert(id, (wx, wy));
|
||||
if first {
|
||||
self.active = true;
|
||||
self.start = (wx, wy);
|
||||
self.max_fingers = 0;
|
||||
self.moved = false;
|
||||
self.scrolling = false;
|
||||
self.scroll_count = 0;
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag.
|
||||
self.drag_held = t - self.last_tap_up < TAP_DRAG_MS
|
||||
&& (wx - self.last_tap_pt.0).abs() < TAP_SLOP
|
||||
&& (wy - self.last_tap_pt.1).abs() < TAP_SLOP;
|
||||
self.last_tap_up = 0.0; // consume the arming either way
|
||||
if !self.trackpad {
|
||||
acts.push(Act::MoveAbs(abs)); // pointer: place the cursor before any press
|
||||
}
|
||||
if self.drag_held {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true,
|
||||
});
|
||||
}
|
||||
self.track_id = Some(id);
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
self.carry = (0.0, 0.0);
|
||||
}
|
||||
self.max_fingers = self.max_fingers.max(self.positions.len());
|
||||
acts
|
||||
}
|
||||
|
||||
/// A finger moved.
|
||||
pub fn motion(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
if !self.active || !self.positions.contains_key(&id) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.positions.insert(id, (wx, wy));
|
||||
if self.positions.len() >= 2 {
|
||||
self.scroll_by_centroid()
|
||||
} else if !self.scrolling {
|
||||
// One finger, and the gesture never became a scroll (dropping back from two
|
||||
// fingers to one must not jerk the cursor).
|
||||
self.single_finger(id, wx, wy, abs, t)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger lifted. Only when the LAST finger lifts does the gesture conclude (into a
|
||||
/// click / drag-end / stats cycle). `t` is the up-time in milliseconds.
|
||||
pub fn up(&mut self, id: u64, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
self.positions.remove(&id);
|
||||
if self.track_id == Some(id) {
|
||||
self.track_id = None;
|
||||
}
|
||||
if !self.positions.is_empty() || !self.active {
|
||||
return acts; // other fingers still down (or no live gesture)
|
||||
}
|
||||
self.active = false;
|
||||
if self.drag_held {
|
||||
self.drag_held = false;
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false,
|
||||
}); // end the held drag
|
||||
} else if !self.moved {
|
||||
match self.max_fingers {
|
||||
n if n >= 3 => acts.push(Act::CycleStats),
|
||||
2 => {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: true,
|
||||
});
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: false,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true,
|
||||
});
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false,
|
||||
});
|
||||
self.last_tap_up = t; // arm tap-drag
|
||||
self.last_tap_pt = self.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// Forget all in-flight gesture state (capture release / session teardown). Any left
|
||||
/// button the engine is holding is released by the owner's held-button flush, so this
|
||||
/// only clears state — it never re-emits wire events.
|
||||
pub fn reset(&mut self) {
|
||||
self.positions.clear();
|
||||
self.track_id = None;
|
||||
self.active = false;
|
||||
self.scrolling = false;
|
||||
self.moved = false;
|
||||
self.drag_held = false;
|
||||
self.last_tap_up = 0.0;
|
||||
}
|
||||
|
||||
/// Two (or more) fingers → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `SCROLL_DIV` px of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
fn scroll_by_centroid(&mut self) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
let n = self.positions.len() as f32;
|
||||
let (mut sx, mut sy) = (0.0f32, 0.0f32);
|
||||
for &(px, py) in self.positions.values() {
|
||||
sx += px;
|
||||
sy += py;
|
||||
}
|
||||
let (cx, cy) = (sx / n, sy / n);
|
||||
// (Re-)anchor on scroll start AND whenever the finger count changes.
|
||||
if !self.scrolling || self.positions.len() != self.scroll_count {
|
||||
self.scrolling = true;
|
||||
self.scroll_count = self.positions.len();
|
||||
self.scroll_anchor = (cx, cy);
|
||||
}
|
||||
let notches_y = ((self.scroll_anchor.1 - cy) / SCROLL_DIV) as i32;
|
||||
let notches_x = ((cx - self.scroll_anchor.0) / SCROLL_DIV) as i32;
|
||||
if notches_y != 0 {
|
||||
acts.push(Act::Scroll {
|
||||
axis: 0,
|
||||
delta: notches_y * 120,
|
||||
});
|
||||
self.scroll_anchor.1 = cy;
|
||||
self.moved = true;
|
||||
}
|
||||
if notches_x != 0 {
|
||||
acts.push(Act::Scroll {
|
||||
axis: 1,
|
||||
delta: notches_x * 120,
|
||||
});
|
||||
self.scroll_anchor.0 = cx;
|
||||
self.moved = true;
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// One finger, not scrolling: trackpad relative ballistics, or pointer absolute follow.
|
||||
fn single_finger(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
if (wx - self.start.0).abs() > TAP_SLOP || (wy - self.start.1).abs() > TAP_SLOP {
|
||||
self.moved = true;
|
||||
}
|
||||
if !self.trackpad {
|
||||
acts.push(Act::MoveAbs(abs)); // the cursor follows the finger
|
||||
return acts;
|
||||
}
|
||||
// Re-anchor (zero delta this frame) if the tracked finger changed, so lifting one of
|
||||
// several fingers never jumps the cursor.
|
||||
if self.track_id != Some(id) {
|
||||
self.track_id = Some(id);
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
return acts;
|
||||
}
|
||||
let dx = wx - self.prev.0;
|
||||
let dy = wy - self.prev.1;
|
||||
let dt_ms = (t - self.prev_t).max(1.0) as f32;
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
let speed = dx.hypot(dy) / dt_ms; // finger px per ms
|
||||
let accel = (1.0 + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).max(0.0)).min(ACCEL_MAX);
|
||||
let gain = POINTER_SENS * accel;
|
||||
self.carry.0 += dx * gain;
|
||||
self.carry.1 += dy * gain;
|
||||
let out_x = self.carry.0 as i32; // truncates toward zero → remainder kept with sign
|
||||
let out_y = self.carry.1 as i32;
|
||||
if out_x != 0 || out_y != 0 {
|
||||
acts.push(Act::MoveRel {
|
||||
dx: out_x,
|
||||
dy: out_y,
|
||||
});
|
||||
self.carry.0 -= out_x as f32;
|
||||
self.carry.1 -= out_y as f32;
|
||||
}
|
||||
acts
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ABS: Abs = Abs {
|
||||
x: 100,
|
||||
y: 200,
|
||||
w: 1280,
|
||||
h: 720,
|
||||
};
|
||||
|
||||
fn abs_at(x: i32, y: i32) -> Abs {
|
||||
Abs {
|
||||
x,
|
||||
y,
|
||||
w: 1280,
|
||||
h: 720,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_tap_is_a_left_click_with_no_motion() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.up(1, 40.0));
|
||||
// A trackpad tap places no cursor and moves nothing — just a click.
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_tap_places_the_cursor_then_clicks() {
|
||||
let mut g = Gestures::new(false);
|
||||
let mut acts = g.down(1, 50.0, 50.0, abs_at(640, 360), 0.0);
|
||||
acts.extend(g.up(1, 40.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::MoveAbs(abs_at(640, 360)),
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_finger_tap_is_a_right_click() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.down(2, 80.0, 52.0, ABS, 5.0));
|
||||
acts.extend(g.up(1, 40.0));
|
||||
acts.extend(g.up(2, 42.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_finger_tap_cycles_stats() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.down(2, 80.0, 50.0, ABS, 2.0));
|
||||
acts.extend(g.down(3, 110.0, 50.0, ABS, 4.0));
|
||||
acts.extend(g.up(1, 40.0));
|
||||
acts.extend(g.up(2, 41.0));
|
||||
acts.extend(g.up(3, 42.0));
|
||||
assert_eq!(acts, vec![Act::CycleStats]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_drag_emits_relative_motion() {
|
||||
let mut g = Gestures::new(true);
|
||||
assert!(g.down(1, 100.0, 100.0, ABS, 0.0).is_empty());
|
||||
// A big move over 16 ms — relative, with acceleration, so it should exceed 1:1.
|
||||
let acts = g.motion(1, 140.0, 100.0, ABS, 16.0);
|
||||
match acts.as_slice() {
|
||||
[Act::MoveRel { dx, dy }] => {
|
||||
assert!(*dx >= 40, "expected accelerated dx ≥ raw 40, got {dx}");
|
||||
assert_eq!(*dy, 0);
|
||||
}
|
||||
other => panic!("expected one MoveRel, got {other:?}"),
|
||||
}
|
||||
// The gesture moved, so the lift is not a tap (no click).
|
||||
assert!(g.up(1, 32.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_motion_follows_the_finger_absolutely() {
|
||||
let mut g = Gestures::new(false);
|
||||
let _ = g.down(1, 100.0, 100.0, abs_at(300, 300), 0.0);
|
||||
let acts = g.motion(1, 140.0, 120.0, abs_at(360, 340), 16.0);
|
||||
assert_eq!(acts, vec![Act::MoveAbs(abs_at(360, 340))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_finger_pan_scrolls_by_the_centroid() {
|
||||
let mut g = Gestures::new(true);
|
||||
let _ = g.down(1, 100.0, 200.0, ABS, 0.0);
|
||||
let _ = g.down(2, 120.0, 200.0, ABS, 2.0);
|
||||
// Both fingers slide up 40 px → the centroid rises 40 px → +ve (finger-up) notches.
|
||||
let a1 = g.motion(1, 100.0, 160.0, ABS, 10.0);
|
||||
let a2 = g.motion(2, 120.0, 160.0, ABS, 12.0);
|
||||
let scrolls: Vec<_> = a1.into_iter().chain(a2).collect();
|
||||
assert!(
|
||||
scrolls
|
||||
.iter()
|
||||
.any(|a| matches!(a, Act::Scroll { axis: 0, delta } if *delta > 0)),
|
||||
"expected an upward vertical scroll, got {scrolls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_then_press_drag_holds_the_left_button() {
|
||||
let mut g = Gestures::new(true);
|
||||
// Tap at (50,50), lifting at t=10.
|
||||
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
let click = g.up(1, 10.0);
|
||||
assert_eq!(
|
||||
click,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
// A new touch nearby within the window arms a held drag: button down on touch, and
|
||||
// the whole gesture holds it until the lift.
|
||||
let down2 = g.down(2, 52.0, 51.0, ABS, 120.0);
|
||||
assert_eq!(
|
||||
down2,
|
||||
vec![Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
}]
|
||||
);
|
||||
let _ = g.motion(2, 90.0, 51.0, ABS, 140.0); // drag
|
||||
let end = g.up(2, 160.0);
|
||||
assert_eq!(
|
||||
end,
|
||||
vec![Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_a_drag_without_re_emitting() {
|
||||
let mut g = Gestures::new(true);
|
||||
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
let _ = g.up(1, 5.0); // arm
|
||||
let _ = g.down(2, 51.0, 50.0, ABS, 50.0); // drag begins (left held)
|
||||
g.reset();
|
||||
// After a reset a fresh tap is an ordinary click (no stuck drag state).
|
||||
let mut acts = g.down(3, 400.0, 400.0, ABS, 500.0);
|
||||
acts.extend(g.up(3, 510.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -50,29 +50,38 @@ pub struct GamepadFrame {
|
||||
pub rs_y: i16,
|
||||
}
|
||||
|
||||
// buttonFlags bits (Limelight.h).
|
||||
pub const BTN_DPAD_UP: u32 = 0x0001;
|
||||
pub const BTN_DPAD_DOWN: u32 = 0x0002;
|
||||
pub const BTN_DPAD_LEFT: u32 = 0x0004;
|
||||
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
|
||||
pub const BTN_START: u32 = 0x0010;
|
||||
pub const BTN_BACK: u32 = 0x0020;
|
||||
pub const BTN_LS_CLK: u32 = 0x0040;
|
||||
pub const BTN_RS_CLK: u32 = 0x0080;
|
||||
pub const BTN_LB: u32 = 0x0100;
|
||||
pub const BTN_RB: u32 = 0x0200;
|
||||
pub const BTN_GUIDE: u32 = 0x0400;
|
||||
pub const BTN_A: u32 = 0x1000;
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
|
||||
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
|
||||
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
|
||||
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
|
||||
// instead of re-declaring the values (the two drifted while separately hand-typed: the click bits
|
||||
// were named `BTN_LS_CLK`/`BTN_RS_CLK` here vs the core `…_CLICK`). `decode` merges the two 16-bit
|
||||
// halves into `buttons` raw; these names exist for the uinput injector's button map + hat math. The
|
||||
// extended touchpad-click / Share bits (`BTN_TOUCHPAD` / `BTN_MISC1`) ride `buttons` too but are
|
||||
// consumed straight from `punktfunk_core` by the DualSense/DS4 protos, so they aren't re-named here.
|
||||
//
|
||||
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
|
||||
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
|
||||
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
|
||||
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
|
||||
use punktfunk_core::input::gamepad as wire;
|
||||
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
|
||||
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
|
||||
pub const BTN_DPAD_LEFT: u32 = wire::BTN_DPAD_LEFT;
|
||||
pub const BTN_DPAD_RIGHT: u32 = wire::BTN_DPAD_RIGHT;
|
||||
pub const BTN_START: u32 = wire::BTN_START;
|
||||
pub const BTN_BACK: u32 = wire::BTN_BACK;
|
||||
pub const BTN_LS_CLICK: u32 = wire::BTN_LS_CLICK;
|
||||
pub const BTN_RS_CLICK: u32 = wire::BTN_RS_CLICK;
|
||||
pub const BTN_LB: u32 = wire::BTN_LB;
|
||||
pub const BTN_RB: u32 = wire::BTN_RB;
|
||||
pub const BTN_GUIDE: u32 = wire::BTN_GUIDE;
|
||||
pub const BTN_A: u32 = wire::BTN_A;
|
||||
pub const BTN_B: u32 = wire::BTN_B;
|
||||
pub const BTN_X: u32 = wire::BTN_X;
|
||||
pub const BTN_Y: u32 = wire::BTN_Y;
|
||||
pub const BTN_PADDLE1: u32 = wire::BTN_PADDLE1;
|
||||
pub const BTN_PADDLE2: u32 = wire::BTN_PADDLE2;
|
||||
pub const BTN_PADDLE3: u32 = wire::BTN_PADDLE3;
|
||||
pub const BTN_PADDLE4: u32 = wire::BTN_PADDLE4;
|
||||
|
||||
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
|
||||
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
|
||||
|
||||
@@ -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,10 +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`]), 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`).
|
||||
@@ -517,12 +539,15 @@ 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Shared by the Linux and Windows DualSense/DS4 backends (the slot-less pads that must fold the
|
||||
/// Steam back grips); the Deck motion rescale is Linux-only but harmless to compile on Windows.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/steam_remap.rs"]
|
||||
pub mod steam_remap;
|
||||
/// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean,
|
||||
@@ -531,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,17 +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, 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::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).
|
||||
@@ -34,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,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,
|
||||
@@ -54,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)
|
||||
@@ -65,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(())
|
||||
}
|
||||
@@ -124,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
|
||||
@@ -152,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 {
|
||||
@@ -162,189 +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)>,
|
||||
/// 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>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// 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],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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.broken {
|
||||
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.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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,19 +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 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";
|
||||
@@ -30,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;
|
||||
@@ -43,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,
|
||||
@@ -128,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);
|
||||
@@ -264,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
|
||||
@@ -291,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) {
|
||||
@@ -310,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
|
||||
@@ -338,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 {
|
||||
@@ -348,304 +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>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// 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],
|
||||
broken: false,
|
||||
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.broken {
|
||||
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();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
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]
|
||||
@@ -657,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,6 +19,7 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
||||
use crate::inject::pad_slots::PadSlots;
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
@@ -88,8 +89,8 @@ const BUTTON_MAP: [(u32, u16); 15] = [
|
||||
(gamepad::BTN_BACK, BTN_SELECT),
|
||||
(gamepad::BTN_START, BTN_START),
|
||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
||||
(gamepad::BTN_LS_CLICK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLICK, BTN_THUMBR),
|
||||
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
|
||||
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
|
||||
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
|
||||
@@ -265,7 +266,6 @@ struct Effect {
|
||||
/// One virtual X-Box-360 pad backed by a uinput device.
|
||||
pub struct VirtualPad {
|
||||
fd: OwnedFd,
|
||||
prev_buttons: u32,
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
@@ -369,7 +369,6 @@ impl VirtualPad {
|
||||
|
||||
Ok(VirtualPad {
|
||||
fd,
|
||||
prev_buttons: 0,
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
@@ -412,15 +411,17 @@ impl VirtualPad {
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT.
|
||||
/// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT.
|
||||
pub fn apply(&mut self, f: &GamepadFrame) {
|
||||
let changed = self.prev_buttons ^ f.buttons;
|
||||
// Re-assert every mapped button's absolute state each frame — exactly like the axes below —
|
||||
// instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops
|
||||
// the write), so an edge-only scheme would strand a dropped press/release until that button
|
||||
// next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is
|
||||
// free downstream: the kernel input core discards an EV_KEY whose value already matches the
|
||||
// device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat).
|
||||
for (bit, key) in BUTTON_MAP {
|
||||
if changed & bit != 0 {
|
||||
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
||||
}
|
||||
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
||||
}
|
||||
self.prev_buttons = f.buttons;
|
||||
|
||||
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
|
||||
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
|
||||
@@ -550,15 +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,
|
||||
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
fn default() -> GamepadManager {
|
||||
GamepadManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
@@ -570,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,
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,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) => {
|
||||
@@ -589,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);
|
||||
}
|
||||
}
|
||||
@@ -608,27 +609,121 @@ impl GamepadManager {
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match VirtualPad::create(idx, self.identity) {
|
||||
Ok(p) => self.pads[idx] = Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
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,12 +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::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;
|
||||
@@ -77,10 +77,12 @@ fn try_clear_lizard_mode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
|
||||
/// 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.
|
||||
@@ -89,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)
|
||||
@@ -100,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);
|
||||
@@ -125,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());
|
||||
@@ -142,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
|
||||
@@ -234,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),
|
||||
@@ -339,161 +358,216 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it).
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it). This is a
|
||||
// DEGRADED outcome, not a normal one: a UHID device has no USB interface number (Interface: -1),
|
||||
// so Steam Input ignores it and the controller never appears in Game Mode / can't navigate.
|
||||
// Reaching here almost always means `vhci_hcd` isn't loaded (the host runs unprivileged and
|
||||
// can't modprobe it) — load it at boot (packaging ships modules-load.d/punktfunk.conf +
|
||||
// 60-punktfunk.rules; on a systemd-sysext host `punktfunk-sysext` mirrors both into /etc).
|
||||
let p = SteamDeckPad::open(idx)?;
|
||||
tracing::info!(
|
||||
tracing::warn!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)"
|
||||
"virtual Steam Deck created as UHID hid-steam — Steam Input WON'T promote it (no USB \
|
||||
interface), so it won't appear in Game Mode. Load vhci_hcd (usbip) so the pad arrives as a \
|
||||
real USB device: `sudo modprobe vhci_hcd`, and ensure it loads at boot."
|
||||
);
|
||||
Ok(DeckTransport::Uhid(p))
|
||||
}
|
||||
|
||||
pub struct SteamControllerManager {
|
||||
pads: Vec<Option<DeckTransport>>,
|
||||
state: Vec<SteamState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_write: Vec<Instant>,
|
||||
broken: bool,
|
||||
}
|
||||
/// 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],
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
/// 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);
|
||||
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.broken {
|
||||
return;
|
||||
}
|
||||
match open_transport(idx as u8) {
|
||||
Ok(t) => {
|
||||
self.pads[idx] = Some(t);
|
||||
self.state[idx] = SteamState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel handshake and forward rumble on the universal plane.
|
||||
/// `rumble` fires `(index, low, high)` only on a level change. The Steam Deck has no rich
|
||||
/// host→client feedback plane (no lightbar / adaptive triggers), so `hidout` goes unused.
|
||||
pub fn pump(&mut self, mut rumble: impl FnMut(u16, u16, u16), _hidout: impl FnMut(HidOutput)) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = pad.service() {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
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::*;
|
||||
@@ -603,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>;
|
||||