Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6241639042 | |||
| 7c72899a49 |
@@ -1,18 +0,0 @@
|
|||||||
# 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,16 +30,6 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
|||||||
|
|
||||||
## Before you push
|
## 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
|
```sh
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|||||||
@@ -49,14 +49,12 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
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 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
|
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||||
@@ -84,10 +82,7 @@ fun GamepadSettingsScreen(
|
|||||||
var s by remember { mutableStateOf(initial) }
|
var s by remember { mutableStateOf(initial) }
|
||||||
fun update(next: Settings) { s = next; onChange(next) }
|
fun update(next: Settings) { s = next; onChange(next) }
|
||||||
|
|
||||||
val context = LocalContext.current
|
val rows = buildSettingsRows(s, ::update)
|
||||||
// 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) }
|
var focus by remember { mutableIntStateOf(0) }
|
||||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||||
@@ -262,13 +257,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
/** 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, update: (Settings) -> Unit): List<GpRow> {
|
||||||
private fun buildSettingsRows(
|
|
||||||
s: Settings,
|
|
||||||
hasBodyVibrator: Boolean,
|
|
||||||
update: (Settings) -> Unit,
|
|
||||||
): List<GpRow> {
|
|
||||||
fun <T> choice(
|
fun <T> choice(
|
||||||
id: String, header: String?, label: String, detail: String,
|
id: String, header: String?, label: String, detail: String,
|
||||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||||
@@ -364,18 +354,7 @@ private fun buildSettingsRows(
|
|||||||
"The virtual pad the host creates — Automatic matches this controller.",
|
"The virtual pad the host creates — Automatic matches this controller.",
|
||||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { 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(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package io.unom.punktfunk
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyCharacterMap
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -154,18 +153,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||||
val vk = Keymap.toVk(event)
|
val vk = Keymap.toVk(event)
|
||||||
if (vk != 0) {
|
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)
|
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
|
return true // consumed — don't let the system also act on it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,14 +82,6 @@ data class Settings(
|
|||||||
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
* otherwise misfire and wait out its timeout despite the host already being reachable.
|
||||||
*/
|
*/
|
||||||
val autoWakeEnabled: Boolean = true,
|
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. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
@@ -150,7 +142,6 @@ class SettingsStore(context: Context) {
|
|||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -171,7 +162,6 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +197,6 @@ class SettingsStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
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. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
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
|
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
|
||||||
@@ -415,18 +414,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
|||||||
subtitle = "What the app detects, with a live input test",
|
subtitle = "What the app detects, with a live input test",
|
||||||
onClick = onOpenControllers,
|
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,22 +6,15 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.InputType
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
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 android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -41,7 +34,6 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import io.unom.punktfunk.kit.VideoDecoders
|
import io.unom.punktfunk.kit.VideoDecoders
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -174,12 +166,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
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
|
// 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
|
// 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
|
// activity declares configChanges=orientation, so this re-lays out the surface in place without
|
||||||
@@ -202,13 +188,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
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
|
// 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
|
// index via the router; poll threads stopped + joined before the router is released and the
|
||||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
// session closed.
|
||||||
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
|
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||||
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
|
// 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.
|
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||||
router.onSlotClosed = feedback::onDeviceRemoved
|
router.onSlotClosed = feedback::onDeviceRemoved
|
||||||
@@ -220,8 +201,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
activity?.requestStreamExit = null
|
activity?.requestStreamExit = null
|
||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
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())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@@ -242,9 +221,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
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()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -295,16 +271,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
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
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
// 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(
|
Box(
|
||||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||||
when (touchMode) {
|
when (touchMode) {
|
||||||
@@ -313,45 +281,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
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,10 +19,6 @@ private const val TAP_SLOP = 12f
|
|||||||
private const val TAP_DRAG_MS = 250L
|
private const val TAP_DRAG_MS = 250L
|
||||||
private const val SCROLL_DIV = 4f
|
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 →
|
// 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
|
// 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
|
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
|
||||||
@@ -44,9 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
|||||||
*
|
*
|
||||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
* 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
|
* 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
|
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||||
@@ -100,7 +94,6 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onCycleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
onKeyboard: (show: Boolean) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -135,12 +128,6 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
var maxFingers = 1
|
var maxFingers = 1
|
||||||
var scrolling = false
|
var scrolling = false
|
||||||
var scrollCount = 0 // pointer count the scroll centroid is anchored at
|
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 prevCx = startX
|
||||||
var prevCy = startY
|
var prevCy = startY
|
||||||
var upTime = down.uptimeMillis
|
var upTime = down.uptimeMillis
|
||||||
@@ -161,12 +148,9 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (pressed.size > maxFingers) maxFingers = pressed.size
|
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) {
|
if (pressed.size >= 2) {
|
||||||
// Two fingers → scroll by the centroid delta; never move the cursor.
|
// Two+ fingers → scroll by the centroid delta; never move the cursor.
|
||||||
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
|
||||||
val cy = (pressed.sumOf { it.position.y.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
|
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
|
||||||
@@ -193,36 +177,6 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
prevCx = cx
|
prevCx = cx
|
||||||
moved = true
|
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) {
|
} else if (!scrolling) {
|
||||||
// One finger (skipped once a gesture turned into a scroll, so dropping
|
// One finger (skipped once a gesture turned into a scroll, so dropping
|
||||||
// back to one finger doesn't jerk the cursor).
|
// back to one finger doesn't jerk the cursor).
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package io.unom.punktfunk.kit
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
@@ -34,18 +33,8 @@ import java.nio.ByteBuffer
|
|||||||
*
|
*
|
||||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
* 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.
|
* 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(
|
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||||
private val handle: Long,
|
|
||||||
private val router: GamepadRouter?,
|
|
||||||
private val deviceVibrator: Vibrator? = null,
|
|
||||||
) {
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "pf.feedback"
|
const val TAG = "pf.feedback"
|
||||||
const val TAG_LED: Byte = 0x01
|
const val TAG_LED: Byte = 0x01
|
||||||
@@ -138,9 +127,7 @@ class GamepadFeedback(
|
|||||||
runCatching { hidoutThread?.join() }
|
runCatching { hidoutThread?.join() }
|
||||||
rumbleThread = null
|
rumbleThread = null
|
||||||
hidoutThread = null
|
hidoutThread = null
|
||||||
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
|
// Threads are dead — drop any held rumble and close every lights session.
|
||||||
// lights session.
|
|
||||||
runCatching { deviceVibrator?.cancel() }
|
|
||||||
synchronized(bindsLock) {
|
synchronized(bindsLock) {
|
||||||
for (b in rumbleBinds.values) b?.let {
|
for (b in rumbleBinds.values) b?.let {
|
||||||
runCatching { it.vm?.cancel() }
|
runCatching { it.vm?.cancel() }
|
||||||
@@ -216,11 +203,6 @@ class GamepadFeedback(
|
|||||||
*/
|
*/
|
||||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
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
|
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 bind = rumbleBindFor(pad) ?: return
|
||||||
val lo = toAmplitude(low)
|
val lo = toAmplitude(low)
|
||||||
val hi = toAmplitude(high)
|
val hi = toAmplitude(high)
|
||||||
@@ -264,29 +246,6 @@ class GamepadFeedback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
private fun toAmplitude(v16: Int): Int {
|
private fun toAmplitude(v16: Int): Int {
|
||||||
val a = (v16 ushr 8) and 0xFF
|
val a = (v16 ushr 8) and 0xFF
|
||||||
@@ -390,18 +349,3 @@ class GamepadFeedback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This device's own body vibrator (the phone, not a controller), or null where there is none
|
|
||||||
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
|
|
||||||
* [GamepadFeedback.deviceVibrator] when it's on.
|
|
||||||
*/
|
|
||||||
fun deviceBodyVibrator(context: Context): Vibrator? {
|
|
||||||
val v = if (Build.VERSION.SDK_INT >= 31) {
|
|
||||||
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
|
||||||
}
|
|
||||||
return v?.takeIf { it.hasVibrator() }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import PunktfunkKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
#if os(iOS)
|
|
||||||
import CoreHaptics
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -41,9 +38,6 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
@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
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -236,7 +230,7 @@ struct GamepadSettingsView: View {
|
|||||||
.map { (label: "\($0) Hz", tag: $0) }
|
.map { (label: "\($0) Hz", tag: $0) }
|
||||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||||
var list: [Row] = [
|
return [
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||||
label: "Resolution",
|
label: "Resolution",
|
||||||
@@ -335,23 +329,6 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
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
|
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||||
// composed by the per-platform bodies in SettingsView.swift.
|
// composed by the per-platform bodies in SettingsView.swift.
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import CoreHaptics
|
|
||||||
#endif
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -474,12 +471,6 @@ extension SettingsView {
|
|||||||
Text(option.label).tag(option.tag)
|
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)
|
#if !os(tvOS)
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
#endif
|
#endif
|
||||||
@@ -496,11 +487,6 @@ extension SettingsView {
|
|||||||
// for its own footer and has no such toggle to describe.
|
// for its own footer and has no such toggle to describe.
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
#if os(iOS)
|
|
||||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
|
||||||
Text(Self.deviceRumbleFooter)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
Text(Self.gamepadUIFooter)
|
Text(Self.gamepadUIFooter)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -88,13 +88,6 @@ extension SettingsView {
|
|||||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||||
+ "Applies from the next session."
|
+ "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)
|
#if !os(tvOS)
|
||||||
static let gamepadUIFooter =
|
static let gamepadUIFooter =
|
||||||
"When a controller connects, the host list and library switch to a controller-"
|
"When a controller connects, the host list and library switch to a controller-"
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ struct SettingsView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
@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.
|
// 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),
|
// 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).
|
// General on iPad (a two-column layout should never open with an empty detail).
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
// (triggers off, player index unset) and its renderer silenced.
|
// (triggers off, player index unset) and its renderer silenced.
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import CoreHaptics
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
@@ -51,26 +50,9 @@ public final class GamepadFeedback {
|
|||||||
private let routingLock = NSLock()
|
private let routingLock = NSLock()
|
||||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
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) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.manager = manager
|
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
|
// 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.
|
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -207,7 +189,6 @@ public final class GamepadFeedback {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
for r in renderers { r.stop() }
|
for r in renderers { r.stop() }
|
||||||
deviceRumble?.stop()
|
|
||||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||||
// teardown must not replay this session's triggers/LEDs.
|
// teardown must not replay this session's triggers/LEDs.
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -222,10 +203,6 @@ public final class GamepadFeedback {
|
|||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
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 {
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
|||||||
@@ -119,19 +119,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
static let manual = Policy(staleAfter: nil)
|
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 queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
private let policy: Policy
|
private let policy: Policy
|
||||||
private let actuator: Actuator
|
|
||||||
|
|
||||||
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
|
/// 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)
|
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
|
||||||
@@ -209,9 +198,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(policy: Policy = .session, actuator: Actuator = .controller) {
|
init(policy: Policy = .session) {
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.actuator = actuator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||||
@@ -480,10 +468,6 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// engine otherwise, driven by whichever amplitude is stronger.
|
||||||
private func setup() {
|
private func setup() {
|
||||||
if actuator == .device {
|
|
||||||
setupDevice()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let haptics = controller?.haptics else {
|
guard let haptics = controller?.haptics else {
|
||||||
// No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
|
// 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
|
// rumble through GameController (works on Android via the standard Vibrator path, but
|
||||||
@@ -533,41 +517,10 @@ 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(
|
private func makeMotor(
|
||||||
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
|
||||||
) -> Motor? {
|
) -> Motor? {
|
||||||
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
|
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
|
// 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;
|
// (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
|
// letting a haptics-only engine join it is a needless coupling that can get its
|
||||||
@@ -593,7 +546,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
try engine.start()
|
try engine.start()
|
||||||
return Motor(engine: engine, sharpness: sharpness)
|
return Motor(engine: engine, sharpness: sharpness)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed: \(error, privacy: .public)")
|
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,44 +118,3 @@ extension InputCapture {
|
|||||||
]
|
]
|
||||||
#endif
|
#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,8 +3,7 @@
|
|||||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
// 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
|
// 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
|
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||||
// (off → compact → normal → detailed, matching Android) · three-finger swipe up/down =
|
// (off → compact → normal → detailed, matching Android):
|
||||||
// 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
|
// * 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
|
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||||
@@ -62,9 +61,6 @@ final class TouchMouse {
|
|||||||
static let accelGain: CGFloat = 0.6
|
static let accelGain: CGFloat = 0.6
|
||||||
static let accelSpeedFloor: CGFloat = 0.3
|
static let accelSpeedFloor: CGFloat = 0.3
|
||||||
static let accelMax: CGFloat = 3.0
|
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.
|
/// Acceleration multiplier for a finger speed in physical px per ms.
|
||||||
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
static func accel(forSpeed speed: CGFloat) -> CGFloat {
|
||||||
@@ -76,9 +72,6 @@ final class TouchMouse {
|
|||||||
var send: ((PunktfunkInputEvent) -> Void)?
|
var send: ((PunktfunkInputEvent) -> Void)?
|
||||||
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
/// View-space point → host-mode pixels through the letterbox (pointer mode's moves).
|
||||||
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
|
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.
|
/// No gesture in flight (all fingers up) — the view uses this to release its mode latch.
|
||||||
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
|
||||||
@@ -102,11 +95,6 @@ final class TouchMouse {
|
|||||||
private var carryY: CGFloat = 0
|
private var carryY: CGFloat = 0
|
||||||
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
/// Scroll anchor (centroid) — re-anchored every time a notch fires.
|
||||||
private var scrollAnchor = CGPoint.zero
|
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.
|
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||||
private var lastTapUp: TimeInterval = 0
|
private var lastTapUp: TimeInterval = 0
|
||||||
private var lastTapPoint = CGPoint.zero
|
private var lastTapPoint = CGPoint.zero
|
||||||
@@ -126,8 +114,6 @@ final class TouchMouse {
|
|||||||
maxFingers = 0
|
maxFingers = 0
|
||||||
moved = false
|
moved = false
|
||||||
scrolling = false
|
scrolling = false
|
||||||
kbCount = 0
|
|
||||||
kbFired = false
|
|
||||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||||
// button for this whole gesture (laptop-trackpad convention).
|
// button for this whole gesture (laptop-trackpad convention).
|
||||||
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
|
||||||
@@ -154,13 +140,8 @@ final class TouchMouse {
|
|||||||
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
|
||||||
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
|
||||||
}
|
}
|
||||||
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3 bounce
|
if lastPos.count >= 2 {
|
||||||
// re-anchors instead of reading the count change as swipe travel.
|
|
||||||
if lastPos.count < 3 { kbCount = 0 }
|
|
||||||
if lastPos.count == 2 {
|
|
||||||
scrollByCentroid()
|
scrollByCentroid()
|
||||||
} else if lastPos.count >= 3 {
|
|
||||||
keyboardSwipe(in: view)
|
|
||||||
} else if !scrolling, let touch = touches.first(where: {
|
} else if !scrolling, let touch = touches.first(where: {
|
||||||
lastPos[ObjectIdentifier($0)] != nil
|
lastPos[ObjectIdentifier($0)] != nil
|
||||||
}) {
|
}) {
|
||||||
@@ -227,9 +208,9 @@ final class TouchMouse {
|
|||||||
|
|
||||||
// MARK: - Per-event work
|
// MARK: - Per-event work
|
||||||
|
|
||||||
/// Two fingers → scroll by the centroid delta; never move the cursor. Fires a notch per
|
/// Two fingers (or more) → scroll by the centroid delta; never move the cursor. Fires a
|
||||||
/// `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger right
|
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
|
||||||
/// scrolls right (the host WHEEL(120) convention).
|
/// right scrolls right (the host WHEEL(120) convention).
|
||||||
private func scrollByCentroid() {
|
private func scrollByCentroid() {
|
||||||
let n = CGFloat(lastPos.count)
|
let n = CGFloat(lastPos.count)
|
||||||
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
|
||||||
@@ -252,38 +233,6 @@ 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 finger (and the gesture never became a scroll — dropping back from two fingers to
|
||||||
/// one must not jerk the cursor).
|
/// one must not jerk the cursor).
|
||||||
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
private func singleFinger(_ touch: UITouch, in view: UIView) {
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ public enum DefaultsKey {
|
|||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
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
|
/// 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…"
|
/// 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
|
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||||
|
|||||||
@@ -698,7 +698,6 @@ final class StreamLayerUIView: UIView {
|
|||||||
let mouse = TouchMouse()
|
let mouse = TouchMouse()
|
||||||
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
|
||||||
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
|
||||||
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
|
|
||||||
return mouse
|
return mouse
|
||||||
}()
|
}()
|
||||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||||
@@ -709,22 +708,6 @@ final class StreamLayerUIView: UIView {
|
|||||||
func resetTouchInput() {
|
func resetTouchInput() {
|
||||||
touchMouse.reset()
|
touchMouse.reset()
|
||||||
fingerRoute = nil
|
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
|
#endif
|
||||||
|
|
||||||
@@ -896,46 +879,4 @@ final class StreamLayerUIView: UIView {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
#endif
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 869 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
@@ -12,7 +12,7 @@
|
|||||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
# 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
|
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||||
# every host (and every pinned game):
|
# every host (and every pinned game):
|
||||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
# PF_HOST host[:port] to connect to (required)
|
||||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
# 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_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||||
@@ -36,31 +36,24 @@ set -u
|
|||||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
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
|
# 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).
|
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
# --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).
|
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||||
if [ -n "${PF_BROWSE:-}" ]; then
|
if [ -n "${PF_BROWSE:-}" ]; then
|
||||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
# The gamepad library launcher: browse the host's games on-screen, A streams one,
|
||||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
# session end returns to the launcher, B quits back to Gaming Mode.
|
||||||
# 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
|
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||||
if [ -n "${PF_MGMT:-}" ]; then
|
if [ -n "${PF_MGMT:-}" ]; then
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||||
fi
|
fi
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||||
fi
|
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
|
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
# 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
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||||
|
|||||||
@@ -1,757 +0,0 @@
|
|||||||
"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,93 +89,6 @@ def _pins_path() -> Path:
|
|||||||
return _client_config_dir() / "decky-pinned.json"
|
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]:
|
def _parse_library_tsv(stdout: str) -> list[dict]:
|
||||||
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
|
"""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
|
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
|
||||||
@@ -813,10 +726,10 @@ class Plugin:
|
|||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
async def shortcut_art(self) -> dict:
|
async def shortcut_art(self) -> dict:
|
||||||
"""The Steam-shortcut artwork shipped with the plugin (committed under ``assets/``):
|
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
|
||||||
base64 PNGs (grid/gridwide/hero/logo) for SetCustomArtworkForApp plus the icon's
|
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
|
||||||
absolute path for SetShortcutIcon (which wants a file, not bytes). Missing files are
|
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
|
||||||
simply omitted — artwork is cosmetic and must never block a launch."""
|
files are simply omitted — artwork is cosmetic and must never block a launch."""
|
||||||
art: dict = {}
|
art: dict = {}
|
||||||
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
|
||||||
for key, fname in (
|
for key, fname in (
|
||||||
@@ -833,54 +746,6 @@ class Plugin:
|
|||||||
art["icon_path"] = str(icon) if icon.exists() else ""
|
art["icon_path"] = str(icon) if icon.exists() else ""
|
||||||
return art
|
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:
|
async def runner_info(self) -> dict:
|
||||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
"""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
|
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#!/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,12 +26,8 @@ cp main.py plugin.json package.json LICENSE "$DEST/"
|
|||||||
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
|
||||||
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
|
||||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||||
# Steam-shortcut artwork (grid/gridwide/hero/logo/icon — committed under assets/).
|
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
|
||||||
cp assets/*.png "$DEST/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 decky.pyi ] && cp decky.pyi "$DEST/"
|
||||||
[ -f README.md ] && cp README.md "$DEST/"
|
[ -f README.md ] && cp README.md "$DEST/"
|
||||||
|
|
||||||
|
|||||||
@@ -150,14 +150,6 @@ export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: str
|
|||||||
);
|
);
|
||||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
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 getSettings = callable<[], StreamSettings>("get_settings");
|
||||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||||
"set_settings",
|
"set_settings",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
import { streamPin } from "./library";
|
import { streamPin } from "./library";
|
||||||
import { PunktfunkRoute, ROUTE } from "./page";
|
import { PunktfunkRoute, ROUTE } from "./page";
|
||||||
import { PairModal } from "./pair";
|
import { PairModal } from "./pair";
|
||||||
import { ensureGamepadUiShortcut } from "./steam";
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------
|
||||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||||
@@ -197,10 +196,6 @@ const QamPanel: FC = () => {
|
|||||||
|
|
||||||
export default definePlugin(() => {
|
export default definePlugin(() => {
|
||||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
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 {
|
return {
|
||||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
// `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".
|
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
// Launch Punktfunk as Steam games so gamescope focuses + fullscreens them.
|
// Launch the stream as a Steam game so gamescope focuses + fullscreens it.
|
||||||
//
|
//
|
||||||
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
|
// 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
|
// 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 non-Steam
|
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
|
||||||
// shortcuts whose exe is `/bin/sh` running our wrapper script (bin/punktfunkrun.sh), and start
|
// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
|
||||||
// them with RunGame. The wrapper then execs the flatpak client as a reaper descendant.
|
// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
|
||||||
//
|
// and start it with RunGame. The wrapper then execs
|
||||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||||
// 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 { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
import { runnerInfo, shortcutArt, wake } from "./backend";
|
||||||
|
|
||||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
// 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
|
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||||
@@ -55,33 +46,32 @@ declare const collectionStore:
|
|||||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
// The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
|
||||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
// carries proper artwork and living in the library is how users relaunch their last host.
|
||||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
|
||||||
|
function unhideShortcut(appId: number): void {
|
||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
try {
|
try {
|
||||||
collectionStore?.SetAppsAsHidden?.([appId], hidden);
|
collectionStore?.SetAppsAsHidden?.([appId], false);
|
||||||
} catch {
|
} catch {
|
||||||
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
/* overview not registered yet, or the API changed — cosmetic, ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
attempt(); // succeeds immediately for an already-registered (reused) shortcut
|
||||||
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
|
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 a shortcut (idempotent, once per ART_VERSION per
|
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
|
||||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
|
||||||
*/
|
*/
|
||||||
async function applyArtwork(appId: number): Promise<void> {
|
async function applyArtwork(appId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const art = await shortcutArt();
|
const art = await shortcutArt();
|
||||||
@@ -99,14 +89,13 @@ async function applyArtwork(appId: number): Promise<void> {
|
|||||||
if (art.icon_path) {
|
if (art.icon_path) {
|
||||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||||
}
|
}
|
||||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shortcut name is user-visible (Steam overlay + library) — brand-case it. BOTH shortcuts
|
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
|
||||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
|
||||||
const SHORTCUT_NAME = "Punktfunk";
|
const SHORTCUT_NAME = "Punktfunk";
|
||||||
|
|
||||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||||
@@ -122,128 +111,76 @@ function gameIdFromAppId(appId: number): string {
|
|||||||
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
return ((BigInt(appId) << 32n) | 0x02000000n).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist each shortcut's appId across reloads so we reuse ONE per role instead of churning the
|
// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the
|
||||||
// library (an appId is stable for the life of the shortcut). The STREAM key is the historical
|
// library (the appId is stable for the life of the shortcut).
|
||||||
// one, so existing single-shortcut installs migrate into the (now hidden) stream role, and the
|
const STORAGE_KEY = "punktfunk:shortcutAppId";
|
||||||
// visible gamepad-UI shortcut is created alongside.
|
|
||||||
const STORAGE_KEY_STREAM = "punktfunk:shortcutAppId";
|
|
||||||
const STORAGE_KEY_UI = "punktfunk:uiAppId";
|
|
||||||
|
|
||||||
function remember(key: string, appId: number) {
|
function rememberAppId(appId: number) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, String(appId));
|
localStorage.setItem(STORAGE_KEY, String(appId));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function recall(key: string): number | null {
|
function recallAppId(): number | null {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(key);
|
const v = localStorage.getItem(STORAGE_KEY);
|
||||||
return v ? Number(v) : null;
|
return v ? Number(v) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 the STREAM shortcut (hidden, stateful) — the per-session launcher whose launch options
|
* Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
|
||||||
* are rewritten per stream. Branded, artworked, native-touch config applied, and HIDDEN (it is
|
* appended per-launch via the launch options), branded and visible in the library, and
|
||||||
* an implementation detail; the visible entry is the gamepad-UI shortcut). Returns its appId +
|
* return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
|
||||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
|
||||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
* the script directly, and pre-0.7 shortcuts were hidden and artless.
|
||||||
*/
|
*/
|
||||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
|
async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
|
||||||
const info = await runnerInfo();
|
const info = await runnerInfo();
|
||||||
if (!info.exists) {
|
if (!info.exists) {
|
||||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||||
}
|
}
|
||||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
|
||||||
|
|
||||||
const remembered = recall(STORAGE_KEY_STREAM);
|
const remembered = recallAppId();
|
||||||
if (remembered != null) {
|
if (remembered != null) {
|
||||||
|
// Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
|
||||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
unhideShortcut(remembered); // pre-0.7 installs hid it
|
||||||
void applyArtwork(remembered);
|
void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
|
||||||
return { appId: remembered, runner: info.runner };
|
return { appId: remembered, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|
||||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||||
setShortcutHidden(appId, true);
|
unhideShortcut(appId);
|
||||||
void applyArtwork(appId);
|
void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
|
||||||
remember(STORAGE_KEY_STREAM, appId);
|
rememberAppId(appId);
|
||||||
return { appId, runner: info.runner };
|
return { appId, runner: info.runner };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
* Best-effort: turn Steam Input OFF for our shortcut so SDL's HIDAPI Steam Deck driver can open the
|
||||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
* Deck's controls (paddles · trackpads · gyro) directly. There is no confirmed-stable SteamClient
|
||||||
* Fixed launch options (no per-session state), branded, artworked, native-touch config applied,
|
* API for this, so it is feature-detected and MUST never block or throw into the launch — the manual
|
||||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
* toggle (game page → ⚙ → Controller Settings → Steam Input Off, surfaced in the plugin Settings) is
|
||||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
* the documented source of truth. No-op when the optional API is absent.
|
||||||
*/
|
*/
|
||||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
function disableSteamInputForShortcut(appId: number): void {
|
||||||
try {
|
try {
|
||||||
const info = await runnerInfo();
|
const input = (
|
||||||
if (!info.exists) {
|
SteamClient as unknown as {
|
||||||
return null;
|
Input?: { SetSteamInputEnabledForApp?: (appId: number, enabled: boolean) => void };
|
||||||
}
|
}
|
||||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
).Input;
|
||||||
void ensureControllerConfig();
|
input?.SetSteamInputEnabledForApp?.(appId, false);
|
||||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
} catch {
|
||||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
/* a controller tweak must never break the launch */
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +210,9 @@ export function isSafeLaunchId(id: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
* library title, or into the gamepad library launcher). Encodes the target into the
|
||||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
* shortcut's launch options (so one generic shortcut serves every host and every pinned
|
||||||
* then RunGame.
|
* game), then RunGame.
|
||||||
*/
|
*/
|
||||||
export async function launchStream(
|
export async function launchStream(
|
||||||
host: string,
|
host: string,
|
||||||
@@ -287,7 +224,10 @@ export async function launchStream(
|
|||||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
// 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.
|
// 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 waking = wake(host, port).catch(() => ({ ok: false }));
|
||||||
const { appId, runner } = await ensureStreamShortcut();
|
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 target = port && port !== 9777 ? `${host}:${port}` : host;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
const env = [`PF_HOST=${target}`];
|
const env = [`PF_HOST=${target}`];
|
||||||
if (opts.browse) {
|
if (opts.browse) {
|
||||||
@@ -311,7 +251,7 @@ export async function launchStream(
|
|||||||
|
|
||||||
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */
|
||||||
export function stopStream(): void {
|
export function stopStream(): void {
|
||||||
const appId = recall(STORAGE_KEY_STREAM);
|
const appId = recallAppId();
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
|
|||||||
/// Codec setting values (persisted) paired with their display labels below.
|
/// Codec setting values (persisted) paired with their display labels below.
|
||||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||||
@@ -324,12 +324,10 @@ pub fn show(
|
|||||||
&dialog,
|
&dialog,
|
||||||
inline,
|
inline,
|
||||||
"Video decoder",
|
"Video decoder",
|
||||||
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
"Automatic tries VAAPI hardware decode, then software",
|
||||||
Vulkan Video on NVIDIA), falling back to software",
|
|
||||||
&[
|
&[
|
||||||
"Automatic (hardware → software)",
|
"Automatic (VAAPI → software)",
|
||||||
"Vulkan Video",
|
"Hardware (VAAPI)",
|
||||||
"VAAPI",
|
|
||||||
"Software",
|
"Software",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
//! exits without connecting.
|
//! exits without connecting.
|
||||||
//!
|
//!
|
||||||
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
|
//! 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]
|
//! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
|
||||||
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
|
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
|
||||||
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
|
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
|
||||||
@@ -52,8 +51,8 @@ use punktfunk_core::config::Role;
|
|||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::FLAG_PROBE;
|
use punktfunk_core::packet::FLAG_PROBE;
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
endpoint, io, window_loss_ppm, BitrateChanged, Hello, LossReport, ProbeRequest, ProbeResult,
|
endpoint, io, window_loss_ppm, Hello, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||||
Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
|
Reconfigured, RequestKeyframe, Start, Welcome,
|
||||||
};
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||||
@@ -85,11 +84,6 @@ struct Args {
|
|||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||||
remode: Option<(Mode, u32)>,
|
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 PIN` — run the pairing ceremony instead of a session.
|
||||||
pair: Option<String>,
|
pair: Option<String>,
|
||||||
/// `--name LABEL` — how the host labels this client when pairing.
|
/// `--name LABEL` — how the host labels this client when pairing.
|
||||||
@@ -207,10 +201,6 @@ fn parse_args() -> Args {
|
|||||||
let (m, secs) = s.split_once(':')?;
|
let (m, secs) = s.split_once(':')?;
|
||||||
Some((parse_mode(m)?, secs.parse().ok()?))
|
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
|
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
|
||||||
// (the user asked for verification; fail closed).
|
// (the user asked for verification; fail closed).
|
||||||
let pin = match get("--pin") {
|
let pin = match get("--pin") {
|
||||||
@@ -262,7 +252,6 @@ fn parse_args() -> Args {
|
|||||||
seconds: get("--seconds").and_then(|s| s.parse().ok()),
|
seconds: get("--seconds").and_then(|s| s.parse().ok()),
|
||||||
pin,
|
pin,
|
||||||
remode,
|
remode,
|
||||||
rebitrate,
|
|
||||||
pair: get("--pair").map(String::from),
|
pair: get("--pair").map(String::from),
|
||||||
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
name: get("--name").unwrap_or("punktfunk-probe").to_string(),
|
||||||
compositor,
|
compositor,
|
||||||
@@ -481,10 +470,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
video_caps: {
|
video_caps: {
|
||||||
// Always ask for per-AU host timings (0xCF) — this is a measurement tool, and the
|
// 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.
|
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING;
|
||||||
// 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() {
|
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||||
}
|
}
|
||||||
@@ -640,64 +626,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
other => tracing::error!(?other, "bad Reconfigured"),
|
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 {
|
} 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
|
// 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.
|
// delivered WIRE packets (session-stat delta) vs. what the host reports putting on the wire.
|
||||||
@@ -711,28 +639,11 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let conn2 = conn.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
// Warm up the stream — and generate desktop activity while doing so. Damage-driven
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // let the stream warm up
|
||||||
// capture paths (Windows IDD-push, a static headless desktop anywhere) publish NO
|
// Baseline the packet-level counters right before the burst (video is paused during it,
|
||||||
// frame until something composes, and the host's pipeline build waits for a first
|
// so the delta is pure probe traffic plus a sliver of resumed video in the settle).
|
||||||
// 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_pkts = rxp.load(Relaxed);
|
||||||
let base_bytes = rxb.load(Relaxed);
|
let base_bytes = rxb.load(Relaxed);
|
||||||
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
tracing::info!(target_kbps, duration_ms, "requesting speed-test probe");
|
||||||
@@ -757,15 +668,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
return;
|
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.
|
// 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
|
// 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).
|
// resumed-video packets against the probe (inflating recv past the host's wire count).
|
||||||
@@ -1248,8 +1150,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
let cap_secs = args.seconds.unwrap_or(120);
|
let cap_secs = args.seconds.unwrap_or(120);
|
||||||
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
|
// 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_loss_report = std::time::Instant::now();
|
||||||
let (mut last_recovered, mut last_late, mut last_received, mut last_dropped) =
|
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||||
(0u64, 0u64, 0u64, 0u64);
|
|
||||||
loop {
|
loop {
|
||||||
// Mirror packet-level receive counters for the speed-test reporter (reads their delta),
|
// 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.
|
// and publish a windowed loss estimate for the adaptive-FEC LossReport task.
|
||||||
@@ -1263,7 +1164,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
lp_dt.store(
|
lp_dt.store(
|
||||||
window_loss_ppm(
|
window_loss_ppm(
|
||||||
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
s.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||||
s.fec_late_shards.wrapping_sub(last_late),
|
|
||||||
s.packets_received.wrapping_sub(last_received),
|
s.packets_received.wrapping_sub(last_received),
|
||||||
s.frames_dropped.wrapping_sub(last_dropped),
|
s.frames_dropped.wrapping_sub(last_dropped),
|
||||||
),
|
),
|
||||||
@@ -1271,7 +1171,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
);
|
);
|
||||||
last_loss_report = std::time::Instant::now();
|
last_loss_report = std::time::Instant::now();
|
||||||
last_recovered = s.fec_recovered_shards;
|
last_recovered = s.fec_recovered_shards;
|
||||||
last_late = s.fec_late_shards;
|
|
||||||
last_received = s.packets_received;
|
last_received = s.packets_received;
|
||||||
last_dropped = s.frames_dropped;
|
last_dropped = s.frames_dropped;
|
||||||
}
|
}
|
||||||
@@ -1343,23 +1242,6 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
s.flush().ok();
|
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();
|
latencies_us.sort_unstable();
|
||||||
let pct = |p: f64| -> u64 {
|
let pct = |p: f64| -> u64 {
|
||||||
if latencies_us.is_empty() {
|
if latencies_us.is_empty() {
|
||||||
|
|||||||
@@ -29,15 +29,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
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 {
|
pub fn run(target: Option<&str>) -> u8 {
|
||||||
let identity = match trust::load_or_create_identity() {
|
let identity = match trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
@@ -137,14 +128,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||||
let json_status = arg_flag("--json-status");
|
let json_status = arg_flag("--json-status");
|
||||||
let settings_at_start = trust::Settings::load();
|
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 {
|
let opts = pf_presenter::SessionOpts {
|
||||||
window_title: window_label.map_or_else(
|
window_title: window_label.map_or_else(
|
||||||
|| "Punktfunk".to_string(),
|
|| "Punktfunk".to_string(),
|
||||||
@@ -159,16 +142,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
},
|
},
|
||||||
touch_mode: settings_at_start.touch_mode(),
|
touch_mode: settings_at_start.touch_mode(),
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
let fp_hex = trust::hex(&fingerprint);
|
trust::touch_last_used(&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)),
|
overlay: Some(Box::new(overlay)),
|
||||||
window_size: crate::session_main::window_size(&settings_at_start),
|
window_size: crate::session_main::window_size(&settings_at_start),
|
||||||
@@ -186,23 +161,21 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
fp_hex,
|
fp_hex,
|
||||||
launch,
|
launch,
|
||||||
title,
|
title,
|
||||||
request_access,
|
|
||||||
} => {
|
} => {
|
||||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
// The console only offers Connect on paired rows; a pinless
|
||||||
// a pinless launch is a logic slip, never a silent TOFU.
|
// launch is a logic slip, never a silent TOFU.
|
||||||
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
tracing::warn!(%addr, "launch without a stored pin — refusing");
|
||||||
return ActionOutcome::Handled;
|
return ActionOutcome::Handled;
|
||||||
};
|
};
|
||||||
tracing::info!(%addr, %title, request_access,
|
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"),
|
||||||
launch = launch.as_deref().unwrap_or("desktop"),
|
|
||||||
"launching from the console");
|
"launching from the console");
|
||||||
// Settings re-load per launch: the console's own settings screen
|
// Settings re-load per launch: the console's own settings screen
|
||||||
// may have changed them since the last stream.
|
// may have changed them since the last stream.
|
||||||
let settings = trust::Settings::load();
|
let settings = trust::Settings::load();
|
||||||
let mut params = session_params(
|
ActionOutcome::Start(Box::new(session_params(
|
||||||
&settings,
|
&settings,
|
||||||
addr.clone(),
|
addr,
|
||||||
port,
|
port,
|
||||||
pin,
|
pin,
|
||||||
identity.clone(),
|
identity.clone(),
|
||||||
@@ -211,20 +184,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
native,
|
native,
|
||||||
force_software,
|
force_software,
|
||||||
vulkan,
|
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::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
||||||
OverlayAction::Quit => ActionOutcome::Quit,
|
OverlayAction::Quit => ActionOutcome::Quit,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
//! Video decode: reassembled HEVC access units → frames for the presenter.
|
||||||
//!
|
//!
|
||||||
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
|
//! Three backends, picked at session start (auto: vulkan → vaapi → software;
|
||||||
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
|
|
||||||
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
|
|
||||||
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
|
||||||
//!
|
//!
|
||||||
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
|
||||||
@@ -386,11 +384,8 @@ impl Decoder {
|
|||||||
/// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's
|
/// `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.
|
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||||
/// hatch, and the documented knob), then the setting; both default to auto.
|
/// hatch, and the documented knob), then the setting; both default to auto
|
||||||
/// Auto's hardware order on Linux depends on the device
|
/// (Vulkan → VAAPI → software; no VAAPI on Windows).
|
||||||
/// ([`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(
|
pub fn new(
|
||||||
codec_id: ffmpeg::codec::Id,
|
codec_id: ffmpeg::codec::Id,
|
||||||
pref: &str,
|
pref: &str,
|
||||||
@@ -410,31 +405,6 @@ impl Decoder {
|
|||||||
want_keyframe: false,
|
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") {
|
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||||||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
// `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
|
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||||||
@@ -453,7 +423,7 @@ impl Decoder {
|
|||||||
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
||||||
}
|
}
|
||||||
tracing::info!(reason = %format!("{e:#}"),
|
tracing::info!(reason = %format!("{e:#}"),
|
||||||
"Vulkan Video unavailable — falling back");
|
"Vulkan Video unavailable — trying VAAPI");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None if choice == "vulkan" => {
|
None if choice == "vulkan" => {
|
||||||
@@ -465,13 +435,12 @@ impl Decoder {
|
|||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Deck/NVIDIA note: `auto` reaches VAAPI here when Vulkan Video isn't available
|
// Deck note: `auto` reaches VAAPI when Vulkan Video isn't available. A presenter
|
||||||
// (on desktop Mesa it was already tried above — `vaapi_tried` skips the repeat).
|
// that can't display the dmabufs demotes this decoder to software mid-session
|
||||||
// A presenter that can't display the dmabufs demotes this decoder to software
|
// via [`Decoder::force_software`]. Windows has no VAAPI — auto falls straight
|
||||||
// mid-session via [`Decoder::force_software`]. Windows has no VAAPI — auto falls
|
// through to software there.
|
||||||
// straight through to software there.
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
if choice != "software" && choice != "vulkan" {
|
||||||
match VaapiDecoder::new(codec_id) {
|
match VaapiDecoder::new(codec_id) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||||
@@ -589,24 +558,6 @@ impl Decoder {
|
|||||||
self.vaapi_fails += 1;
|
self.vaapi_fails += 1;
|
||||||
self.want_keyframe = true;
|
self.want_keyframe = true;
|
||||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
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,
|
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||||
"{which} decode failing repeatedly — demoting to software");
|
"{which} decode failing repeatedly — demoting to software");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
@@ -1051,12 +1002,6 @@ pub struct VulkanDecodeDevice {
|
|||||||
pub instance: usize,
|
pub instance: usize,
|
||||||
pub physical_device: usize,
|
pub physical_device: usize,
|
||||||
pub 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).
|
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||||||
pub graphics_qf: u32,
|
pub graphics_qf: u32,
|
||||||
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
||||||
@@ -1090,25 +1035,6 @@ pub struct VulkanDecodeDevice {
|
|||||||
pub queue_lock: std::sync::Arc<QueueLock>,
|
pub queue_lock: std::sync::Arc<QueueLock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VulkanDecodeDevice {
|
|
||||||
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device? Only where that's the
|
|
||||||
/// established right answer:
|
|
||||||
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
|
|
||||||
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
|
|
||||||
/// * **VanGogh (Steam Deck)** — VAAPI's separate-plane dmabuf import shows chroma
|
|
||||||
/// fringing there; the session binary opts RADV into `video_decode` precisely to
|
|
||||||
/// get the Vulkan path.
|
|
||||||
///
|
|
||||||
/// Every other Mesa device (desktop RADV, ANV) keeps the battle-tested zero-copy
|
|
||||||
/// VAAPI first: Mesa now exposes decode queues by default, and FFmpeg-Vulkan-on-Mesa
|
|
||||||
/// regressing (judder, error-streaks that used to demote to software) is a real,
|
|
||||||
/// user-reported failure — while VAAPI is the path every other Linux client uses.
|
|
||||||
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
|
|
||||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
|
||||||
self.vendor_id == VENDOR_NVIDIA || self.device_name.to_ascii_uppercase().contains("VANGOGH")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
/// `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 {
|
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)
|
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||||
@@ -1579,50 +1505,6 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 ONLY on NVIDIA (no usable VAAPI) and the
|
|
||||||
/// Deck's VanGogh (VAAPI chroma-fringes); desktop RADV/ANV keep VAAPI first — the
|
|
||||||
/// user-reported judder/software regression came from FFmpeg-Vulkan-on-Mesa winning auto.
|
|
||||||
#[test]
|
|
||||||
fn vulkan_over_vaapi_only_on_nvidia_and_vangogh() {
|
|
||||||
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 {
|
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||||
ColorDesc {
|
ColorDesc {
|
||||||
primaries: 1,
|
primaries: 1,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ pub(crate) enum GlyphStyle {
|
|||||||
impl GlyphStyle {
|
impl GlyphStyle {
|
||||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||||
match pref {
|
match pref {
|
||||||
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
Some(
|
||||||
GlyphStyle::Shapes
|
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
|
||||||
}
|
) => GlyphStyle::Shapes,
|
||||||
Some(_) => GlyphStyle::Letters,
|
Some(_) => GlyphStyle::Letters,
|
||||||
None => GlyphStyle::Keyboard,
|
None => GlyphStyle::Keyboard,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ pub(crate) struct ConnectIntent {
|
|||||||
pub launch: Option<String>,
|
pub launch: Option<String>,
|
||||||
/// What the connecting card says (host or game title).
|
/// What the connecting card says (host or game title).
|
||||||
pub title: String,
|
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 {
|
pub(crate) enum Nav {
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ impl HomeScreen {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
request_access: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ impl LibraryScreen {
|
|||||||
fp_hex: self.fp_hex.clone(),
|
fp_hex: self.fp_hex.clone(),
|
||||||
launch: Some(g.id.clone()),
|
launch: Some(g.id.clone()),
|
||||||
title: g.title.clone(),
|
title: g.title.clone(),
|
||||||
request_access: false,
|
|
||||||
});
|
});
|
||||||
Some(MenuPulse::Confirm)
|
Some(MenuPulse::Confirm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
use crate::glyphs::{Hint, HintKey};
|
||||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
use crate::screens::{Ctx, Outbox};
|
||||||
use crate::theme::{Fonts, DIM, ERROR, W};
|
use crate::theme::{Fonts, DIM, ERROR, W};
|
||||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
@@ -18,24 +18,10 @@ enum Field {
|
|||||||
Device,
|
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 {
|
pub(crate) struct PairScreen {
|
||||||
host_name: String,
|
host_name: String,
|
||||||
addr: String,
|
addr: String,
|
||||||
port: u16,
|
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,
|
list: MenuList,
|
||||||
keyboard: Keyboard,
|
keyboard: Keyboard,
|
||||||
pin: String,
|
pin: String,
|
||||||
@@ -53,7 +39,6 @@ impl PairScreen {
|
|||||||
host_name: host.name.clone(),
|
host_name: host.name.clone(),
|
||||||
addr: host.addr.clone(),
|
addr: host.addr.clone(),
|
||||||
port: host.port,
|
port: host.port,
|
||||||
fp_hex: host.fp_hex.clone(),
|
|
||||||
list: MenuList::new(),
|
list: MenuList::new(),
|
||||||
keyboard: Keyboard::new(),
|
keyboard: Keyboard::new(),
|
||||||
pin: String::new(),
|
pin: String::new(),
|
||||||
@@ -64,25 +49,6 @@ 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 {
|
pub(crate) fn host_name(&self) -> &str {
|
||||||
&self.host_name
|
&self.host_name
|
||||||
}
|
}
|
||||||
@@ -204,29 +170,13 @@ impl PairScreen {
|
|||||||
fx.pop();
|
fx.pop();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let roles = self.roles();
|
let (msg, pulse) = self.list.menu(ev, 3);
|
||||||
let (msg, pulse) = self.list.menu(ev, roles.len());
|
|
||||||
match msg {
|
match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
match roles.get(self.list.cursor) {
|
match self.list.cursor {
|
||||||
Some(Role::RequestAccess) if !self.busy => {
|
0 => self.editing = Some(Field::Pin),
|
||||||
// The no-PIN path: connect and park until the operator approves this
|
1 => self.editing = Some(Field::Device),
|
||||||
// device on the host. The shell shows the approval takeover; on
|
_ if self.can_pair() => {
|
||||||
// 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.busy = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
fx.cmds.push(ConsoleCmd::Pair {
|
fx.cmds.push(ConsoleCmd::Pair {
|
||||||
@@ -241,11 +191,8 @@ impl PairScreen {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Pair with no PIN yet (or a request while busy) — jump into the
|
// No PIN yet — jump into the PIN field instead of a dead press.
|
||||||
// PIN field instead of a dead press.
|
self.list.cursor = 0;
|
||||||
if let Some(i) = roles.iter().position(|r| *r == Role::Pin) {
|
|
||||||
self.list.cursor = i;
|
|
||||||
}
|
|
||||||
self.editing = Some(Field::Pin);
|
self.editing = Some(Field::Pin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,14 +233,9 @@ impl PairScreen {
|
|||||||
ctx: &mut Ctx,
|
ctx: &mut Ctx,
|
||||||
) {
|
) {
|
||||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
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(
|
fonts.centered(
|
||||||
canvas,
|
canvas,
|
||||||
intro,
|
"Enter the PIN from the host's web console (Pairing page) or its log.",
|
||||||
W::Regular,
|
W::Regular,
|
||||||
13.0 * k,
|
13.0 * k,
|
||||||
DIM,
|
DIM,
|
||||||
@@ -375,39 +317,19 @@ impl PairScreen {
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.trim_end()
|
.trim_end()
|
||||||
.to_string();
|
.to_string();
|
||||||
let has_request = self.can_request();
|
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
|
||||||
self.roles()
|
pin.caret = self.editing == Some(Field::Pin);
|
||||||
.into_iter()
|
let mut device = RowSpec::field(
|
||||||
.map(|role| match role {
|
"Device name",
|
||||||
Role::RequestAccess => {
|
self.device.clone(),
|
||||||
let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
|
"How the host lists this device",
|
||||||
r.header = Some("No PIN needed");
|
);
|
||||||
r
|
device.caret = self.editing == Some(Field::Device);
|
||||||
}
|
vec![
|
||||||
Role::Pin => {
|
pin,
|
||||||
let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
|
device,
|
||||||
pin.caret = self.editing == Some(Field::Pin);
|
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,43 +388,6 @@ mod tests {
|
|||||||
assert!(fx.cmds.is_empty());
|
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]
|
#[test]
|
||||||
fn pin_is_digits_only() {
|
fn pin_is_digits_only() {
|
||||||
let mut s = PairScreen::new(&host(), "d");
|
let mut s = PairScreen::new(&host(), "d");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -28,11 +28,10 @@ enum RowId {
|
|||||||
Mic,
|
Mic,
|
||||||
Pad,
|
Pad,
|
||||||
PadType,
|
PadType,
|
||||||
Touch,
|
|
||||||
Stats,
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: [RowId; 13] = [
|
const ROWS: [RowId; 12] = [
|
||||||
RowId::Resolution,
|
RowId::Resolution,
|
||||||
RowId::Refresh,
|
RowId::Refresh,
|
||||||
RowId::Bitrate,
|
RowId::Bitrate,
|
||||||
@@ -44,7 +43,6 @@ const ROWS: [RowId; 13] = [
|
|||||||
RowId::Mic,
|
RowId::Mic,
|
||||||
RowId::Pad,
|
RowId::Pad,
|
||||||
RowId::PadType,
|
RowId::PadType,
|
||||||
RowId::Touch,
|
|
||||||
RowId::Stats,
|
RowId::Stats,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -243,11 +241,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
"Controller type",
|
"Controller type",
|
||||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||||
),
|
),
|
||||||
RowId::Touch => (
|
|
||||||
Some("Touchscreen"),
|
|
||||||
"Touch mode",
|
|
||||||
s.touch_mode().label().into(),
|
|
||||||
),
|
|
||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
@@ -285,10 +278,6 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
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::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
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 => {
|
RowId::Stats => {
|
||||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
@@ -359,11 +348,6 @@ 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())
|
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::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 => {
|
RowId::Stats => {
|
||||||
let cur = StatsVerbosity::ALL
|
let cur = StatsVerbosity::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -478,35 +462,6 @@ mod tests {
|
|||||||
assert!(!ctx.settings.mic_enabled);
|
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]
|
#[test]
|
||||||
fn unknown_value_snaps_to_first() {
|
fn unknown_value_snaps_to_first() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ struct Connecting {
|
|||||||
title: String,
|
title: String,
|
||||||
canceling: bool,
|
canceling: bool,
|
||||||
appear: f64,
|
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.
|
/// What the session binary hands the shell at construction.
|
||||||
@@ -155,7 +152,6 @@ impl Shell {
|
|||||||
title,
|
title,
|
||||||
canceling: false,
|
canceling: false,
|
||||||
appear: 0.0,
|
appear: 0.0,
|
||||||
request_access: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => self.connecting = None,
|
None => self.connecting = None,
|
||||||
@@ -240,7 +236,6 @@ impl Shell {
|
|||||||
fp_hex: h.fp_hex.clone(),
|
fp_hex: h.fp_hex.clone(),
|
||||||
launch: None,
|
launch: None,
|
||||||
title: h.name.clone(),
|
title: h.name.clone(),
|
||||||
request_access: false,
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
self.bus.send(ConsoleCmd::CancelWake);
|
self.bus.send(ConsoleCmd::CancelWake);
|
||||||
@@ -259,16 +254,12 @@ impl Shell {
|
|||||||
|
|
||||||
fn start_connect(&mut self, intent: ConnectIntent) {
|
fn start_connect(&mut self, intent: ConnectIntent) {
|
||||||
self.set_connecting(Some(intent.title.clone()));
|
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 {
|
self.actions.push_back(OverlayAction::Launch {
|
||||||
addr: intent.addr,
|
addr: intent.addr,
|
||||||
port: intent.port,
|
port: intent.port,
|
||||||
fp_hex: intent.fp_hex,
|
fp_hex: intent.fp_hex,
|
||||||
launch: intent.launch,
|
launch: intent.launch,
|
||||||
title: intent.title,
|
title: intent.title,
|
||||||
request_access: intent.request_access,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,17 +629,6 @@ impl Shell {
|
|||||||
String::new(),
|
String::new(),
|
||||||
vec![],
|
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 {
|
} else {
|
||||||
Some((
|
Some((
|
||||||
c.appear,
|
c.appear,
|
||||||
|
|||||||
@@ -538,11 +538,10 @@ pub mod gamepad {
|
|||||||
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
||||||
/// codec plus the four native back/Fn button bits).
|
/// codec plus the four native back/Fn button bits).
|
||||||
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
||||||
/// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured
|
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for
|
||||||
/// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by
|
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a
|
||||||
/// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
|
/// software-devnode HID Deck?) — never stamped by a session.
|
||||||
/// (the wired controller interface — the N4-spike finding).
|
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3;
|
||||||
pub const DEVTYPE_STEAMDECK: u8 = 3;
|
|
||||||
|
|
||||||
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
||||||
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ pub enum OverlayAction {
|
|||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
title: 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.
|
/// 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.
|
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||||||
|
|||||||
@@ -427,6 +427,47 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
// events on desktop, and the door Steam's on-screen keyboard types through under
|
// events on desktop, and the door Steam's on-screen keyboard types through under
|
||||||
// gamescope). Toggled edge-wise — start/stop are not free on Wayland.
|
// gamescope). Toggled edge-wise — start/stop are not free on Wayland.
|
||||||
let mut text_input_on = false;
|
let mut text_input_on = false;
|
||||||
|
// One-shot on-glass touch diagnostics. Under the Deck's game-mode gamescope, Steam Input
|
||||||
|
// owns the physical touchscreen and by default emulates it as a virtual trackpad/mouse —
|
||||||
|
// so the app may see MouseMotion/MouseButton instead of the Finger* events the touch-mode
|
||||||
|
// engine feeds on (which kills BOTH trackpad and passthrough at once). Set
|
||||||
|
// `PUNKTFUNK_TOUCH_DEBUG=1` to log every raw finger AND mouse event: one run tells us
|
||||||
|
// whether native wl_touch is being delivered (Finger* with direct=true) or intercepted.
|
||||||
|
let touch_debug = std::env::var_os("PUNKTFUNK_TOUCH_DEBUG").is_some();
|
||||||
|
// Under the Deck's game-mode gamescope the session binary's stderr is swallowed by Steam's
|
||||||
|
// reaper, so ALSO mirror the debug lines to a file in the app data dir (host-visible at
|
||||||
|
// ~/.var/app/io.unom.Punktfunk/…), pulled over SSH after a run.
|
||||||
|
let mut touch_log: Option<std::fs::File> = touch_debug
|
||||||
|
.then(|| {
|
||||||
|
let dir = std::env::var_os("XDG_DATA_HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
|
let path = dir.join("punktfunk-touch-debug.log");
|
||||||
|
match std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
|
Ok(f) => {
|
||||||
|
tracing::info!(path = %path.display(), "touch-debug: mirroring to file");
|
||||||
|
Some(f)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "touch-debug: file sink open failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
// Defined after `touch_log` so the literal identifier resolves to that local; a no-op when
|
||||||
|
// the sink is absent (env unset or open failed).
|
||||||
|
macro_rules! touch_file_log {
|
||||||
|
($($arg:tt)*) => {
|
||||||
|
if let Some(f) = touch_log.as_mut() {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = writeln!(f, $($arg)*);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let outcome = 'main: loop {
|
let outcome = 'main: loop {
|
||||||
// --- SDL events (input, window, gamepads) ---------------------------------------
|
// --- SDL events (input, window, gamepads) ---------------------------------------
|
||||||
@@ -558,11 +599,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::MouseMotion { xrel, yrel, .. } => {
|
Event::MouseMotion { xrel, yrel, .. } => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(xrel, yrel, "touch-debug: MouseMotion");
|
||||||
|
touch_file_log!("MouseMotion xrel={xrel} yrel={yrel}");
|
||||||
|
}
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
cap.on_motion(xrel, yrel);
|
cap.on_motion(xrel, yrel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::MouseButtonDown { mouse_btn, .. } => {
|
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(?mouse_btn, "touch-debug: MouseButtonDown");
|
||||||
|
touch_file_log!("MouseButtonDown mouse_btn={mouse_btn:?}");
|
||||||
|
}
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if !cap.captured() {
|
if !cap.captured() {
|
||||||
// The engaging click is suppressed toward the host.
|
// The engaging click is suppressed toward the host.
|
||||||
@@ -574,6 +623,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::MouseButtonUp { mouse_btn, .. } => {
|
Event::MouseButtonUp { mouse_btn, .. } => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(?mouse_btn, "touch-debug: MouseButtonUp");
|
||||||
|
touch_file_log!("MouseButtonUp mouse_btn={mouse_btn:?}");
|
||||||
|
}
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
cap.on_button_up(mouse_btn);
|
cap.on_button_up(mouse_btn);
|
||||||
}
|
}
|
||||||
@@ -597,6 +650,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
timestamp,
|
timestamp,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(
|
||||||
|
touch_id,
|
||||||
|
finger_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
direct = is_direct_touch(touch_id),
|
||||||
|
"touch-debug: FingerDown"
|
||||||
|
);
|
||||||
|
touch_file_log!(
|
||||||
|
"FingerDown touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
|
||||||
|
is_direct_touch(touch_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
if is_direct_touch(touch_id)
|
if is_direct_touch(touch_id)
|
||||||
&& dispatch_finger(
|
&& dispatch_finger(
|
||||||
FingerPhase::Down,
|
FingerPhase::Down,
|
||||||
@@ -619,6 +686,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
timestamp,
|
timestamp,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(
|
||||||
|
touch_id,
|
||||||
|
finger_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
direct = is_direct_touch(touch_id),
|
||||||
|
"touch-debug: FingerMotion"
|
||||||
|
);
|
||||||
|
touch_file_log!(
|
||||||
|
"FingerMotion touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
|
||||||
|
is_direct_touch(touch_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
if is_direct_touch(touch_id)
|
if is_direct_touch(touch_id)
|
||||||
&& dispatch_finger(
|
&& dispatch_finger(
|
||||||
FingerPhase::Move,
|
FingerPhase::Move,
|
||||||
@@ -641,6 +722,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
timestamp,
|
timestamp,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
if touch_debug {
|
||||||
|
tracing::info!(
|
||||||
|
touch_id,
|
||||||
|
finger_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
direct = is_direct_touch(touch_id),
|
||||||
|
"touch-debug: FingerUp"
|
||||||
|
);
|
||||||
|
touch_file_log!(
|
||||||
|
"FingerUp touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
|
||||||
|
is_direct_touch(touch_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
if is_direct_touch(touch_id)
|
if is_direct_touch(touch_id)
|
||||||
&& dispatch_finger(
|
&& dispatch_finger(
|
||||||
FingerPhase::Up,
|
FingerPhase::Up,
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
|
//! 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
|
//! 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
|
//! overlay tier.
|
||||||
//! 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
|
//! 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
|
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
|
||||||
|
|||||||
@@ -661,11 +661,6 @@ impl Presenter {
|
|||||||
instance: instance.handle().as_raw() as usize,
|
instance: instance.handle().as_raw() as usize,
|
||||||
physical_device: pdev.as_raw() as usize,
|
physical_device: pdev.as_raw() as usize,
|
||||||
device: device.handle().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_qf: qfi,
|
||||||
graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(),
|
graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(),
|
||||||
decode_qf,
|
decode_qf,
|
||||||
|
|||||||
@@ -885,9 +885,9 @@ pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
|||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
/// 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.
|
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||||
/// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||||
/// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||||
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
/// 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.
|
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||||
|
|||||||
@@ -15,16 +15,12 @@
|
|||||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||||
//! evidence there is.
|
//! evidence there is.
|
||||||
//!
|
//!
|
||||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, or ≥6 % loss) backs off ×0.7
|
//! AIMD shape: two consecutive bad windows ⇒ multiplicative decrease (×0.7, floored); ~10 s of
|
||||||
//! immediately; ordinary congestion (heavy-but-recoverable loss, an OWD rise) needs two
|
//! clean windows ⇒ additive-ish increase (+~6 %, ceilinged at the session's starting rate — the
|
||||||
//! consecutive bad windows. Recovery is two-mode: **slow start** — until the first congestion
|
//! controller recovers *back to* what was negotiated, never beyond it). Changes are rate-limited
|
||||||
//! signal the rate DOUBLES each clean window (cooldown-paced), which is how an Automatic session
|
//! (each one costs the IDR the host's rebuilt encoder opens with) and the whole controller
|
||||||
//! climbs from the conservative start to the [`set_ceiling`](BitrateController::set_ceiling)
|
//! disables itself against a host that never answers [`crate::quic::BitrateChanged`] (an older
|
||||||
//! measured by the startup link-capacity probe in seconds instead of minutes — then classic
|
//! build that ignores unknown control messages).
|
||||||
//! 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::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -32,21 +28,15 @@ use std::time::{Duration, Instant};
|
|||||||
/// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a
|
/// 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.
|
/// mis-measured window from cratering the session.
|
||||||
const FLOOR_KBPS: u32 = 5_000;
|
const FLOOR_KBPS: u32 = 5_000;
|
||||||
/// Consecutive bad windows before an ORDINARY decrease — one window can be a scheduler blip or a
|
/// Consecutive bad windows before a decrease — one window can be a scheduler blip or a single
|
||||||
/// single Wi-Fi scan; two in a row (1.5 s) is a condition. A SEVERE window skips the wait.
|
/// Wi-Fi scan; two in a row (1.5 s) is a condition.
|
||||||
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
||||||
/// Window shard loss at/above which ONE window is enough to back off — 6 % is past any
|
/// Consecutive clean windows before probing back up (~10 s at the 750 ms cadence): recovery is
|
||||||
/// blip/retry tail, and every 750 ms spent there is visible damage. Unrecoverable frames and
|
/// deliberately much slower than backoff, classic AIMD.
|
||||||
/// jump-to-live flushes are severe for the same reason.
|
const CLEAN_WINDOWS_TO_INCREASE: u32 = 13;
|
||||||
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
|
/// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR
|
||||||
/// on the host today (in-place reconfigure is planned), and back-to-back steps would outrun the
|
/// on the host, and back-to-back steps would outrun the ack/effect round trip.
|
||||||
/// ack/effect round trip.
|
const CHANGE_COOLDOWN: Duration = Duration::from_secs(3);
|
||||||
const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
|
|
||||||
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
/// 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.
|
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
||||||
const HEAVY_LOSS_PPM: u32 = 20_000;
|
const HEAVY_LOSS_PPM: u32 = 20_000;
|
||||||
@@ -66,14 +56,9 @@ pub(crate) struct BitrateController {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
||||||
current_kbps: u32,
|
current_kbps: u32,
|
||||||
/// The climb ceiling: the negotiated start rate until the startup link-capacity probe
|
/// The session's starting (negotiated) rate — the recovery ceiling.
|
||||||
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
|
||||||
/// Automatic session scale past its conservative start.
|
|
||||||
ceiling_kbps: u32,
|
ceiling_kbps: u32,
|
||||||
floor_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.
|
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
||||||
owd_means: VecDeque<i64>,
|
owd_means: VecDeque<i64>,
|
||||||
bad_windows: u32,
|
bad_windows: u32,
|
||||||
@@ -93,7 +78,6 @@ impl BitrateController {
|
|||||||
current_kbps: start_kbps,
|
current_kbps: start_kbps,
|
||||||
ceiling_kbps: start_kbps,
|
ceiling_kbps: start_kbps,
|
||||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||||
probing: true,
|
|
||||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||||
bad_windows: 0,
|
bad_windows: 0,
|
||||||
clean_windows: 0,
|
clean_windows: 0,
|
||||||
@@ -102,17 +86,6 @@ 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
|
/// 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).
|
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||||
@@ -161,16 +134,10 @@ impl BitrateController {
|
|||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush) or
|
let bad = dropped > 0 || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || flushed;
|
||||||
// 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 {
|
if bad {
|
||||||
self.bad_windows += 1;
|
self.bad_windows += 1;
|
||||||
self.clean_windows = 0;
|
self.clean_windows = 0;
|
||||||
// Any congestion signal ends slow start for good — from here on, climbs are additive.
|
|
||||||
self.probing = false;
|
|
||||||
} else {
|
} else {
|
||||||
self.clean_windows += 1;
|
self.clean_windows += 1;
|
||||||
self.bad_windows = 0;
|
self.bad_windows = 0;
|
||||||
@@ -181,27 +148,16 @@ impl BitrateController {
|
|||||||
if !cooled {
|
if !cooled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
if self.bad_windows >= BAD_WINDOWS_TO_DECREASE && self.current_kbps > self.floor_kbps {
|
||||||
&& self.current_kbps > self.floor_kbps
|
|
||||||
{
|
|
||||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||||
self.bad_windows = 0;
|
self.bad_windows = 0;
|
||||||
return self.request(next, now);
|
return self.request(next, now);
|
||||||
}
|
}
|
||||||
if self.current_kbps < self.ceiling_kbps {
|
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE && 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).
|
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
|
||||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
self.clean_windows = 0;
|
||||||
if self.probing && self.clean_windows >= 1 {
|
return self.request(next, now);
|
||||||
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
|
None
|
||||||
}
|
}
|
||||||
@@ -248,66 +204,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_ordinary_bad_windows_step_down_multiplicatively() {
|
fn two_bad_windows_step_down_multiplicatively() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
// One bad window is a blip — no reaction.
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 0, 25_000, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||||
// The second consecutive bad window backs off ×0.7.
|
// The second consecutive bad window backs off ×0.7.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 1), 0, 25_000, None, false),
|
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||||
assert_eq!(c.on_window(ticks(start, 6), 0, 25_000, None, false), None); // bad #1 again
|
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None); // bad #1 again
|
||||||
assert_eq!(
|
assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, false), Some(9_800));
|
||||||
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]
|
#[test]
|
||||||
fn cooldown_blocks_back_to_back_steps() {
|
fn cooldown_blocks_back_to_back_steps() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
// Two more bad windows land INSIDE the 3 s cooldown (ticks 2,3 = 1.5/2.25 s) → held.
|
||||||
// boundary (tick 2 = 1.5 s) it fires.
|
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), None);
|
||||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 3), 1, 0, None, false), None);
|
||||||
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), Some(9_800));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floor_is_never_crossed() {
|
fn floor_is_never_crossed() {
|
||||||
let mut c = BitrateController::new(6_000);
|
let mut c = BitrateController::new(6_000);
|
||||||
let start = Instant::now();
|
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.
|
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||||
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), Some(5_000));
|
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), Some(5_000));
|
||||||
c.on_ack(5_000);
|
c.on_ack(5_000);
|
||||||
// At the floor, further bad windows request nothing.
|
// At the floor, further bad windows request nothing.
|
||||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
||||||
@@ -318,76 +252,21 @@ mod tests {
|
|||||||
fn sustained_clean_recovers_toward_ceiling_only() {
|
fn sustained_clean_recovers_toward_ceiling_only() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
assert_eq!(c.on_window(ticks(start, 0), 1, 0, None, false), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
c.on_window(ticks(start, 1), 1, 0, None, false),
|
||||||
Some(14_000)
|
Some(14_000)
|
||||||
);
|
);
|
||||||
c.on_ack(14_000);
|
c.on_ack(14_000);
|
||||||
// The backoff ended slow start → additive recovery: 6 clean windows → one +~6 % step
|
// 13 clean windows → one additive step up (14000 + 14000/16 + 1 = 14876).
|
||||||
// (14000 + 14000/16 + 1 = 14876).
|
let up = run_clean(&mut c, start, 2, 13);
|
||||||
let up = run_clean(&mut c, start, 2, 7);
|
|
||||||
assert_eq!(up, Some(14_876));
|
assert_eq!(up, Some(14_876));
|
||||||
c.on_ack(14_876);
|
c.on_ack(14_876);
|
||||||
// Fully recovered → clean windows at the ceiling stay quiet (never probe past it).
|
// Fully recovered → clean windows at the ceiling stay quiet (never probe past start).
|
||||||
c.on_ack(20_000);
|
c.on_ack(20_000);
|
||||||
assert_eq!(run_clean(&mut c, start, 40, 20), None);
|
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]
|
#[test]
|
||||||
fn owd_rise_alone_is_a_congestion_signal() {
|
fn owd_rise_alone_is_a_congestion_signal() {
|
||||||
let mut c = BitrateController::new(20_000);
|
let mut c = BitrateController::new(20_000);
|
||||||
@@ -425,4 +304,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(sent, MAX_UNACKED);
|
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,15 +1934,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
// 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);
|
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
let mut last_report = Instant::now();
|
let mut last_report = Instant::now();
|
||||||
let (mut last_recovered, mut last_late, mut last_received, mut last_dropped) =
|
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
|
||||||
(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
|
// 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
|
// (`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
|
// echoes 0 → controller stays permanently off). Fed once per report window with the same
|
||||||
@@ -1953,22 +1945,6 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
} else {
|
} else {
|
||||||
0
|
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 owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||||
let mut flush_in_window = false;
|
let mut flush_in_window = false;
|
||||||
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
// Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
|
||||||
@@ -2023,65 +1999,6 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
p.active && !p.done
|
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 {
|
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||||
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
// 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).
|
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||||
@@ -2092,7 +2009,6 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||||
let loss_ppm = window_loss_ppm(
|
let loss_ppm = window_loss_ppm(
|
||||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||||
st.fec_late_shards.wrapping_sub(last_late),
|
|
||||||
st.packets_received.wrapping_sub(last_received),
|
st.packets_received.wrapping_sub(last_received),
|
||||||
window_dropped,
|
window_dropped,
|
||||||
);
|
);
|
||||||
@@ -2119,58 +2035,14 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
flush_in_window = false;
|
flush_in_window = false;
|
||||||
last_report = Instant::now();
|
last_report = Instant::now();
|
||||||
last_recovered = st.fec_recovered_shards;
|
last_recovered = st.fec_recovered_shards;
|
||||||
last_late = st.fec_late_shards;
|
|
||||||
last_received = st.packets_received;
|
last_received = st.packets_received;
|
||||||
last_dropped = st.frames_dropped;
|
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() {
|
match session.poll_frame() {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||||
continue; // speed-test filler, not video — measured via the counters above
|
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 —
|
// 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
|
// 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
|
// stream stays behind for good (observed live: stuck 6–7 s). Pre-decode AUs are
|
||||||
|
|||||||
@@ -160,10 +160,9 @@ pub enum GamepadPref {
|
|||||||
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
||||||
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
||||||
SteamController,
|
SteamController,
|
||||||
/// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips
|
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
|
||||||
/// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs
|
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
|
||||||
/// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows
|
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
||||||
/// (UMDF minidriver, Steam-Input-promoted).
|
|
||||||
SteamDeck,
|
SteamDeck,
|
||||||
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
|
/// 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,
|
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
//! shards/block — this is what removes the GameStream 255-shard / ~1 Gbps wall.
|
//! shards/block — this is what removes the GameStream 255-shard / ~1 Gbps wall.
|
||||||
//! Shard length must be even.
|
//! Shard length must be even.
|
||||||
|
|
||||||
use super::{
|
use super::{validate_block_shape, validate_encode_shape, ErasureCoder, FecError};
|
||||||
validate_block_shape, validate_encode_shape, validate_into_shape, ErasureCoder, FecError,
|
|
||||||
};
|
|
||||||
use crate::config::FecScheme;
|
use crate::config::FecScheme;
|
||||||
|
|
||||||
pub struct Gf16Coder;
|
pub struct Gf16Coder;
|
||||||
@@ -83,46 +81,4 @@ impl ErasureCoder for Gf16Coder {
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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,9 +4,7 @@
|
|||||||
//! client (unlike Vandermonde RS, whose parity is not interoperable). Hard ceiling: data +
|
//! client (unlike Vandermonde RS, whose parity is not interoperable). Hard ceiling: data +
|
||||||
//! recovery ≤ 255 shards/block.
|
//! recovery ≤ 255 shards/block.
|
||||||
|
|
||||||
use super::{
|
use super::{validate_block_shape, validate_encode_shape, ErasureCoder, FecError};
|
||||||
validate_block_shape, validate_encode_shape, validate_into_shape, ErasureCoder, FecError,
|
|
||||||
};
|
|
||||||
use crate::config::FecScheme;
|
use crate::config::FecScheme;
|
||||||
use fec_rs::ReedSolomon;
|
use fec_rs::ReedSolomon;
|
||||||
|
|
||||||
@@ -58,44 +56,6 @@ impl ErasureCoder for Gf8Coder {
|
|||||||
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
.map_err(|_| FecError::Backend("gf8 reconstruct"))?;
|
||||||
collect_originals(received, data_count)
|
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(
|
fn collect_originals(
|
||||||
|
|||||||
@@ -43,25 +43,6 @@ pub trait ErasureCoder: Send + Sync {
|
|||||||
recovery_count: usize,
|
recovery_count: usize,
|
||||||
received: &mut [Option<Vec<u8>>],
|
received: &mut [Option<Vec<u8>>],
|
||||||
) -> Result<Vec<Vec<u8>>, FecError>;
|
) -> 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.
|
/// Construct the coder for a scheme.
|
||||||
@@ -99,43 +80,6 @@ pub(crate) fn validate_block_shape(
|
|||||||
Ok(())
|
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.
|
/// Validate `encode` inputs: at least one data shard, all of equal length.
|
||||||
pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
|
pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
|
||||||
let first = data
|
let first = data
|
||||||
@@ -173,93 +117,6 @@ mod tests {
|
|||||||
assert_eq!(restored, data);
|
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, 16, 4, 256, &[1, 9], &[3]);
|
|
||||||
roundtrip_into(&Gf16Coder, 4, 2, 16, &[0, 3], &[]);
|
|
||||||
roundtrip_into(&Gf16Coder, 4, 2, 16, &[], &[0, 1]); // nothing missing, no parity needed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gf8_reconstruct_into_fills_only_the_holes() {
|
|
||||||
roundtrip_into(&Gf8Coder, 16, 4, 256, &[0, 7], &[1]);
|
|
||||||
roundtrip_into(&Gf8Coder, 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
|
|
||||||
.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
|
|
||||||
.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
|
|
||||||
.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
|
|
||||||
.reconstruct_into(2, &mut slots, &[true; 3], &[(0, &parity)])
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gf8_recovers_within_budget() {
|
fn gf8_recovers_within_budget() {
|
||||||
// 16 data + 4 recovery; lose 2 data + 2 recovery (== budget).
|
// 16 data + 4 recovery; lose 2 data + 2 recovery (== budget).
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::error::{PunktfunkError, Result};
|
|||||||
use crate::fec::ErasureCoder;
|
use crate::fec::ErasureCoder;
|
||||||
use crate::session::Frame;
|
use crate::session::Frame;
|
||||||
use crate::stats::StatsCounters;
|
use crate::stats::StatsCounters;
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
|
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
|
||||||
|
|
||||||
/// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
|
/// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
|
||||||
@@ -331,28 +331,14 @@ impl Packetizer {
|
|||||||
// Client side: reassembly + FEC recovery
|
// Client side: reassembly + FEC recovery
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Per-block reassembly state. The block's DATA bytes live in the owning [`FrameBuf::buf`]
|
struct BlockBuf {
|
||||||
/// (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,
|
data_shards: usize,
|
||||||
recovery_shards: usize,
|
recovery_shards: usize,
|
||||||
/// Per-data-shard presence: which ranges of the frame buffer hold received bytes (also the
|
shard_bytes: usize,
|
||||||
/// FEC input map — the codec reads only present slots).
|
/// Length `data_shards + recovery_shards`; `Some` = received.
|
||||||
have_data: Vec<bool>,
|
shards: Vec<Option<Vec<u8>>>,
|
||||||
data_received: usize,
|
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,
|
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 {
|
struct FrameBuf {
|
||||||
@@ -360,16 +346,9 @@ struct FrameBuf {
|
|||||||
block_count: usize,
|
block_count: usize,
|
||||||
pts_ns: u64,
|
pts_ns: u64,
|
||||||
user_flags: u32,
|
user_flags: u32,
|
||||||
/// The whole frame's data region — `total_data_shards × shard_bytes` zeroed bytes. Data
|
blocks: HashMap<u16, BlockBuf>,
|
||||||
/// shards are copied to their final offset on arrival; FEC reconstruction writes only the
|
/// Reconstructed payload per completed block, ordered by block index.
|
||||||
/// missing shards' ranges. On completion this Vec IS [`Frame::data`] (truncated to
|
block_data: BTreeMap<u16, Vec<u8>>,
|
||||||
/// `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*
|
/// Per-session bounds the reassembler enforces on every packet header *before*
|
||||||
@@ -413,33 +392,15 @@ impl ReassemblerLimits {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ReassemblyWindow {
|
struct ReassemblyWindow {
|
||||||
frames: HashMap<u32, FrameBuf>,
|
frames: HashMap<u32, FrameBuf>,
|
||||||
/// Recently-terminated frames (emitted OR abandoned by the loss window), so stray/late shards
|
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
||||||
/// 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`.
|
/// the reorder window alongside `frames`.
|
||||||
completed: HashMap<u32, Vec<u32>>,
|
completed: HashSet<u32>,
|
||||||
/// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
|
/// 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
|
/// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
|
||||||
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
|
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
|
||||||
newest_frame: Option<(u32, u64)>,
|
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.
|
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||||
/// Client-side only.
|
/// Client-side only.
|
||||||
pub struct Reassembler {
|
pub struct Reassembler {
|
||||||
@@ -453,12 +414,6 @@ pub struct Reassembler {
|
|||||||
/// video loss anchor). Aged-out probe frames are NOT `frames_dropped` — probe loss is measured
|
/// 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.
|
/// bytes-wise by the probe accumulator and must not fire video recovery.
|
||||||
probe: ReassemblyWindow,
|
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 {
|
impl Reassembler {
|
||||||
@@ -467,8 +422,6 @@ impl Reassembler {
|
|||||||
limits,
|
limits,
|
||||||
video: ReassemblyWindow::default(),
|
video: ReassemblyWindow::default(),
|
||||||
probe: ReassemblyWindow::default(),
|
probe: ReassemblyWindow::default(),
|
||||||
recovery_pool: Vec::new(),
|
|
||||||
in_flight_bytes: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,16 +449,7 @@ impl Reassembler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disjoint field borrows: the window (`video`/`probe`), the recovery pool, and the
|
let lim = self.limits;
|
||||||
// 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 shard_bytes = hdr.shard_bytes as usize;
|
||||||
let data_shards = hdr.data_shards as usize;
|
let data_shards = hdr.data_shards as usize;
|
||||||
let recovery_shards = hdr.recovery_shards as usize;
|
let recovery_shards = hdr.recovery_shards as usize;
|
||||||
@@ -536,219 +480,130 @@ impl Reassembler {
|
|||||||
drop(stats);
|
drop(stats);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame
|
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
||||||
// 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
|
// 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
|
// 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
|
// 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).
|
// frames never count as `frames_dropped`, which would fire video loss recovery).
|
||||||
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
||||||
let win = if is_probe { probe } else { video };
|
let win = if is_probe {
|
||||||
win.advance_window(
|
&mut self.probe
|
||||||
hdr.frame_index,
|
} else {
|
||||||
hdr.pts_ns,
|
&mut self.video
|
||||||
stats,
|
|
||||||
!is_probe,
|
|
||||||
recovery_pool,
|
|
||||||
in_flight_bytes,
|
|
||||||
lim.max_data_shards,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
win.advance_window(hdr.frame_index, hdr.pts_ns, stats, !is_probe);
|
||||||
|
|
||||||
|
// 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(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(),
|
||||||
|
});
|
||||||
if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
|
if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
|
||||||
drop(stats);
|
drop(stats);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let FrameBuf {
|
|
||||||
buf,
|
|
||||||
blocks,
|
|
||||||
blocks_ok,
|
|
||||||
..
|
|
||||||
} = frame;
|
|
||||||
|
|
||||||
// First packet of a block sizes its state; `data_shards` is already pinned by the
|
if frame.block_data.contains_key(&hdr.block_index) {
|
||||||
// derived geometry above, but `recovery_shards` is per-block wire input (adaptive FEC
|
return Ok(None); // block already reconstructed; late/duplicate shard
|
||||||
// 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,
|
// First packet of a block sizes its shard vector; later packets must match its
|
||||||
recovery_shards,
|
// (data, recovery, shard_bytes) geometry, so `shard_index` is always in bounds.
|
||||||
have_data: vec![false; data_shards],
|
frame
|
||||||
data_received: 0,
|
.blocks
|
||||||
recovery: vec![None; recovery_shards],
|
.entry(hdr.block_index)
|
||||||
recovery_received: 0,
|
.or_insert_with(|| BlockBuf {
|
||||||
done: false,
|
data_shards,
|
||||||
reconstructed: false,
|
recovery_shards,
|
||||||
});
|
shard_bytes,
|
||||||
if block.recovery_shards != recovery_shards {
|
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
|
||||||
|
{
|
||||||
drop(stats);
|
drop(stats);
|
||||||
return Ok(None);
|
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 shard_index < data_shards {
|
if block.shards[shard_index].is_none() {
|
||||||
// A data shard lands at its final AU offset — the only copy its bytes ever make
|
block.shards[shard_index] = Some(payload);
|
||||||
// past decrypt.
|
block.received += 1;
|
||||||
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.
|
// Reconstruct as soon as we hold enough shards.
|
||||||
if block.data_received + block.recovery_received >= block.data_shards {
|
if !block.done && block.received >= block.data_shards {
|
||||||
let missing = block.data_shards - block.data_received;
|
let present_data = block.shards[..block.data_shards]
|
||||||
let outcome = if missing == 0 {
|
.iter()
|
||||||
Ok(()) // every original arrived — its bytes are already in place
|
.filter(|s| s.is_some())
|
||||||
} else {
|
.count();
|
||||||
let base = block_idx * lim.max_data_shards * shard_bytes;
|
let recovered = match coder.reconstruct(
|
||||||
let region = &mut buf[base..base + block.data_shards * shard_bytes];
|
block.data_shards,
|
||||||
let mut slots: Vec<&mut [u8]> = region.chunks_mut(shard_bytes).collect();
|
block.recovery_shards,
|
||||||
let parity: Vec<(usize, &[u8])> = block
|
&mut block.shards,
|
||||||
.recovery
|
) {
|
||||||
.iter()
|
Ok(r) => r,
|
||||||
.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(_) => {
|
Err(_) => {
|
||||||
// Corrupt/incompatible shards that slipped past the header checks: discard
|
// Corrupt/incompatible shards that slipped past the header checks: discard this
|
||||||
// this block (done, but never counted ok — the frame can't complete and ages
|
// block (mark done so later shards for it are ignored) and keep the session
|
||||||
// out) and keep the session alive; the client recovers at the next
|
// alive — a lossy link must not be torn down by one unrecoverable block; the
|
||||||
// keyframe/RFI.
|
// frame stays incomplete and the client recovers at the next keyframe/RFI.
|
||||||
|
block.done = true;
|
||||||
StatsCounters::add(&stats.packets_dropped, 1);
|
StatsCounters::add(&stats.packets_dropped, 1);
|
||||||
return Ok(None);
|
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?
|
// Whole frame ready?
|
||||||
if *blocks_ok == block_count {
|
if frame.block_data.len() == frame.block_count {
|
||||||
let mut done = win.frames.remove(&hdr.frame_index).unwrap();
|
let frame = win.frames.remove(&hdr.frame_index).unwrap();
|
||||||
win.completed.insert(
|
win.completed.insert(hdr.frame_index);
|
||||||
hdr.frame_index,
|
// Reserve based on the bytes we actually hold, not the (already-bounded but
|
||||||
reconstructed_shards(&done.blocks, lim.max_data_shards),
|
// 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();
|
||||||
*in_flight_bytes -= done.buf.len();
|
let mut data = Vec::with_capacity(actual);
|
||||||
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
|
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
|
||||||
return Ok(Some(Frame {
|
return Ok(Some(Frame {
|
||||||
data: done.buf,
|
data,
|
||||||
frame_index: hdr.frame_index,
|
frame_index: hdr.frame_index,
|
||||||
pts_ns: done.pts_ns,
|
pts_ns: frame.pts_ns,
|
||||||
flags: done.user_flags,
|
flags: frame.user_flags,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -763,45 +618,20 @@ impl Reassembler {
|
|||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.video = ReassemblyWindow::default();
|
self.video = ReassemblyWindow::default();
|
||||||
self.probe = 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 {
|
impl ReassemblyWindow {
|
||||||
/// Track the newest frame, declare incomplete frames that fell out of the loss window
|
/// 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
|
/// ([`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
|
/// 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`].
|
/// recovery-keyframe request — and prune the completed-index memory to [`REORDER_WINDOW`].
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn advance_window(
|
fn advance_window(
|
||||||
&mut self,
|
&mut self,
|
||||||
frame_index: u32,
|
frame_index: u32,
|
||||||
pts_ns: u64,
|
pts_ns: u64,
|
||||||
stats: &StatsCounters,
|
stats: &StatsCounters,
|
||||||
count_drops: bool,
|
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 {
|
let (newest, newest_pts) = match self.newest_frame {
|
||||||
// `frame_index` is newer iff it's within the forward half of the index space.
|
// `frame_index` is newer iff it's within the forward half of the index space.
|
||||||
@@ -818,21 +648,8 @@ impl ReassemblyWindow {
|
|||||||
if !keep {
|
if !keep {
|
||||||
// Remember the abandoned index so a straggler shard is dropped (below, and in
|
// 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
|
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
|
||||||
// and double-count the drop when it aged out again. Blocks that reconstructed
|
// and double-count the drop when it aged out again.
|
||||||
// before the frame died still counted `fec_recovered_shards`, so their restored
|
completed.insert(idx);
|
||||||
// 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
|
keep
|
||||||
});
|
});
|
||||||
@@ -841,7 +658,7 @@ impl ReassemblyWindow {
|
|||||||
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
||||||
}
|
}
|
||||||
self.completed
|
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
|
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
|
||||||
@@ -1140,205 +957,6 @@ 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]
|
#[test]
|
||||||
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
||||||
let coder = coder_for(FecScheme::Gf8);
|
let coder = coder_for(FecScheme::Gf8);
|
||||||
|
|||||||
@@ -415,10 +415,9 @@ pub struct SetBitrate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the
|
/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the
|
||||||
/// request clamped to its supported band). The encoder retargets in place where the backend can
|
/// request clamped to its supported band). The encoder switches on the next frame (an IDR); the
|
||||||
/// (no IDR — the stream carries straight on); a backend without in-place reconfigure rebuilds and
|
/// stream never pauses. Also the controller's liveness signal: no answer ⇒ an old host that
|
||||||
/// switches on the next frame (an IDR). The stream never pauses either way. Also the controller's
|
/// doesn't renegotiate bitrate.
|
||||||
/// liveness signal: no answer ⇒ an old host that doesn't renegotiate bitrate.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct BitrateChanged {
|
pub struct BitrateChanged {
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
@@ -1148,19 +1147,13 @@ impl BitrateChanged {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered
|
/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered
|
||||||
/// (the loss it absorbed), recovered-but-then-arrived shards (`late` — reordered delivery lets a
|
/// (the loss it absorbed), shards received, and frames that went unrecoverable. Loss ≈ recovered /
|
||||||
/// block reconstruct early, so those were never lost; netting them out keeps plain reordering from
|
/// (received + recovered) — the fraction of shards that arrived missing. A frame drop means loss
|
||||||
/// reading as packet loss and spooking adaptive FEC + the bitrate controller), shards received,
|
/// exceeded the current FEC budget (so `recovered` plateaus), so add a fixed bump to push the host's
|
||||||
/// and frames that went unrecoverable. Loss ≈ (recovered − late) / (received + recovered − late) —
|
/// FEC up past the cap on the next adjustment. Returns parts-per-million, capped at 1e6.
|
||||||
/// the fraction of shards that truly never arrived (a late shard is inside `received`, so the
|
pub fn window_loss_ppm(recovered: u64, received: u64, frames_dropped: u64) -> u32 {
|
||||||
/// denominator nets it too; saturating, so reorder straddling a window boundary can't go
|
let denom = received.saturating_add(recovered);
|
||||||
/// negative). A frame drop means loss exceeded the current FEC budget (so `recovered` plateaus),
|
let mut ppm = recovered
|
||||||
/// 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)
|
.saturating_mul(1_000_000)
|
||||||
.checked_div(denom)
|
.checked_div(denom)
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
|
|||||||
@@ -707,22 +707,15 @@ fn loss_report_roundtrip() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn window_loss_ppm_estimates_and_caps() {
|
fn window_loss_ppm_estimates_and_caps() {
|
||||||
// No traffic → 0. A clean window (nothing recovered) → 0.
|
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||||
assert_eq!(window_loss_ppm(0, 0, 0, 0), 0);
|
assert_eq!(window_loss_ppm(0, 0, 0), 0);
|
||||||
assert_eq!(window_loss_ppm(0, 0, 1000, 0), 0);
|
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
|
||||||
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||||
assert_eq!(window_loss_ppm(50, 0, 950, 0), 50_000);
|
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
|
||||||
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||||
assert_eq!(window_loss_ppm(50, 0, 950, 1), 100_000);
|
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
|
||||||
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||||
assert_eq!(window_loss_ppm(0, 0, 0, 3), 50_000);
|
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
|
||||||
assert!(window_loss_ppm(u64::MAX, 0, 1, 9) <= 1_000_000);
|
assert!(window_loss_ppm(u64::MAX, 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]
|
#[test]
|
||||||
|
|||||||
@@ -59,35 +59,11 @@ pub struct Session {
|
|||||||
/// then returns them via [`reclaim_wires`](Self::reclaim_wires)). After warmup each buffer keeps
|
/// 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.
|
/// its capacity, so the per-packet ciphertext + wire `Vec` allocations vanish from the hot path.
|
||||||
wire_pool: Vec<Vec<u8>>,
|
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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`].
|
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
|
||||||
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
|
/// pkt/s this is ~4k syscalls/s instead of 125k; the buffers cost `RECV_BATCH × RECV_BUF` (~64 KB).
|
||||||
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
|
const RECV_BATCH: usize = 32;
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
impl Session {
|
||||||
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
|
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
|
||||||
@@ -115,20 +91,10 @@ impl Session {
|
|||||||
recv_count: 0,
|
recv_count: 0,
|
||||||
recv_idx: 0,
|
recv_idx: 0,
|
||||||
wire_pool: Vec::new(),
|
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),
|
|
||||||
config,
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn role(&self) -> Role {
|
pub fn role(&self) -> Role {
|
||||||
self.config.role
|
self.config.role
|
||||||
}
|
}
|
||||||
@@ -396,14 +362,9 @@ impl Session {
|
|||||||
loop {
|
loop {
|
||||||
// Refill the ring with one `recvmmsg` batch when the current one is drained.
|
// Refill the ring with one `recvmmsg` batch when the current one is drained.
|
||||||
if self.recv_idx >= self.recv_count {
|
if self.recv_idx >= self.recv_count {
|
||||||
let t0 = self.perf.is_some().then(std::time::Instant::now);
|
|
||||||
self.recv_count = self
|
self.recv_count = self
|
||||||
.transport
|
.transport
|
||||||
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
.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;
|
self.recv_idx = 0;
|
||||||
if self.recv_count == 0 {
|
if self.recv_count == 0 {
|
||||||
return Err(PunktfunkError::NoFrame);
|
return Err(PunktfunkError::NoFrame);
|
||||||
@@ -423,9 +384,6 @@ impl Session {
|
|||||||
// one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
|
// 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
|
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
|
||||||
// `recv_scratch` alive across the replay/reassembler calls below.
|
// `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 {
|
let (pkt_range, seq) = match &self.crypto {
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
|
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
|
||||||
@@ -440,9 +398,6 @@ impl Session {
|
|||||||
}
|
}
|
||||||
None => (0..len, None),
|
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
|
// 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
|
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
|
||||||
// is uniform and cheap.
|
// is uniform and cheap.
|
||||||
@@ -457,16 +412,10 @@ impl Session {
|
|||||||
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
||||||
// The reassembler validates the packet via its parsed header (`magic`),
|
// The reassembler validates the packet via its parsed header (`magic`),
|
||||||
// ignoring anything that isn't a well-formed video packet.
|
// ignoring anything that isn't a well-formed video packet.
|
||||||
let t_push = self.perf.is_some().then(std::time::Instant::now);
|
if let Some(frame) = self
|
||||||
let pushed = self
|
|
||||||
.reassembler
|
.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);
|
StatsCounters::add(&self.stats.frames_completed, 1);
|
||||||
return Ok(frame);
|
return Ok(frame);
|
||||||
}
|
}
|
||||||
@@ -484,8 +433,8 @@ impl Session {
|
|||||||
/// (observed live: a stream stuck 6–7 s behind, socket buffers full end to end). Discarding
|
/// (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
|
/// 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
|
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
|
||||||
/// cap (1024 batches ≈ 131k datagrams ≈ 190 MB at the 128-deep ring) only guards against a
|
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
|
||||||
/// line-rate sender outpacing the discard loop indefinitely.
|
/// outpacing the discard loop indefinitely.
|
||||||
pub fn flush_backlog(&mut self) -> Result<u64> {
|
pub fn flush_backlog(&mut self) -> Result<u64> {
|
||||||
if self.config.role != Role::Client {
|
if self.config.role != Role::Client {
|
||||||
return Err(PunktfunkError::InvalidArg(
|
return Err(PunktfunkError::InvalidArg(
|
||||||
@@ -497,7 +446,7 @@ impl Session {
|
|||||||
self.recv_count = 0;
|
self.recv_count = 0;
|
||||||
self.recv_idx = 0;
|
self.recv_idx = 0;
|
||||||
if !self.recv_scratch.is_empty() {
|
if !self.recv_scratch.is_empty() {
|
||||||
for _ in 0..1024 {
|
for _ in 0..4096 {
|
||||||
let n = self
|
let n = self
|
||||||
.transport
|
.transport
|
||||||
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
|
||||||
@@ -543,12 +492,10 @@ fn seq_of(wire: &[u8]) -> u64 {
|
|||||||
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
|
/// ([`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
|
/// 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
|
/// 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 topped out around ~2 Gbps — which the client now
|
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
|
||||||
/// exceeds: the 2026-07-14 zero-copy + hardware-AES work measured ~4.8 Gbps wire ≈ 430k pkt/s
|
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
|
||||||
/// delivered). 131072 covers 120 ms up to ~1.09M pkt/s (≈12 Gbps wire) and is effectively
|
/// could hide; the bitmap costs 4 KiB per session.
|
||||||
/// unbounded for the sparse input stream, while still bounding how far back a replay could
|
const REPLAY_WINDOW: u64 = 32768;
|
||||||
/// hide; the bitmap costs 16 KiB per session.
|
|
||||||
const REPLAY_WINDOW: u64 = 131072;
|
|
||||||
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
||||||
|
|
||||||
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ pub struct Stats {
|
|||||||
/// send path; raise `net.core.wmem_max` / lower the bitrate, or wait for paced batched sending.
|
/// send path; raise `net.core.wmem_max` / lower the bitrate, or wait for paced batched sending.
|
||||||
pub packets_send_dropped: u64,
|
pub packets_send_dropped: u64,
|
||||||
pub fec_recovered_shards: 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_sent: u64,
|
||||||
pub bytes_received: u64,
|
pub bytes_received: u64,
|
||||||
}
|
}
|
||||||
@@ -41,7 +34,6 @@ pub struct StatsCounters {
|
|||||||
pub packets_dropped: AtomicU64,
|
pub packets_dropped: AtomicU64,
|
||||||
pub packets_send_dropped: AtomicU64,
|
pub packets_send_dropped: AtomicU64,
|
||||||
pub fec_recovered_shards: AtomicU64,
|
pub fec_recovered_shards: AtomicU64,
|
||||||
pub fec_late_shards: AtomicU64,
|
|
||||||
pub bytes_sent: AtomicU64,
|
pub bytes_sent: AtomicU64,
|
||||||
pub bytes_received: AtomicU64,
|
pub bytes_received: AtomicU64,
|
||||||
}
|
}
|
||||||
@@ -63,7 +55,6 @@ impl StatsCounters {
|
|||||||
packets_dropped: self.packets_dropped.load(l),
|
packets_dropped: self.packets_dropped.load(l),
|
||||||
packets_send_dropped: self.packets_send_dropped.load(l),
|
packets_send_dropped: self.packets_send_dropped.load(l),
|
||||||
fec_recovered_shards: self.fec_recovered_shards.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_sent: self.bytes_sent.load(l),
|
||||||
bytes_received: self.bytes_received.load(l),
|
bytes_received: self.bytes_received.load(l),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ pub trait Transport: Send + Sync {
|
|||||||
/// ~1 GSO skb per ≤64 segments instead of one skb per packet. This is the multi-Gbps lever —
|
/// ~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`
|
/// 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
|
/// batching alone is insufficient (it still builds one skb per datagram). The
|
||||||
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via
|
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via `PUNKTFUNK_GSO`,
|
||||||
/// `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),
|
/// 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`.
|
/// 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> {
|
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Real UDP datagram transport — native sockets, no async runtime.
|
//! Real UDP datagram transport — native sockets, no async runtime.
|
||||||
//!
|
//!
|
||||||
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
|
||||||
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
|
//! ([`Transport::recv_batch`], ≤32/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`])
|
//! `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
|
//! — 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
|
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
|
||||||
@@ -111,17 +111,8 @@ fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UDP GSO enable state (process-wide). **Opt-in** (`PUNKTFUNK_GSO=1`) — and deliberately so,
|
/// UDP GSO enable state (process-wide). Opt-in via `PUNKTFUNK_GSO` — it's new unsafe hot-path code,
|
||||||
/// measured three times on 2026-07-14: GSO cuts send-thread CPU ~30% at 1250 Mbps, but its
|
/// and the auto-fallback (latch off on any GSO syscall error) covers kernels/paths without support.
|
||||||
/// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod gso {
|
mod gso {
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
@@ -132,19 +123,15 @@ mod gso {
|
|||||||
1 => true,
|
1 => true,
|
||||||
2 => false,
|
2 => false,
|
||||||
_ => {
|
_ => {
|
||||||
// Opt-in: on only when PUNKTFUNK_GSO is set to something other than "0".
|
let on = std::env::var_os("PUNKTFUNK_GSO").is_some();
|
||||||
let on = std::env::var("PUNKTFUNK_GSO").is_ok_and(|v| v != "0");
|
|
||||||
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
|
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
|
||||||
on
|
on
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Latch GSO off for the process after a GSO syscall error (unsupported kernel/path).
|
/// 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() {
|
pub fn disable() {
|
||||||
if STATE.swap(2, Ordering::Relaxed) != 2 {
|
STATE.store(2, Ordering::Relaxed);
|
||||||
tracing::warn!("Linux UDP GSO unsupported on this path — falling back to sendmmsg");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,17 +301,6 @@ pub trait Encoder: Send {
|
|||||||
fn reset(&mut self) -> bool {
|
fn reset(&mut self) -> bool {
|
||||||
false
|
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)
|
/// 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`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
@@ -343,19 +332,6 @@ 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
|
/// 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
|
/// 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
|
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||||
@@ -476,9 +452,6 @@ impl Encoder for TrackedEncoder {
|
|||||||
fn reset(&mut self) -> bool {
|
fn reset(&mut self) -> bool {
|
||||||
self.inner.reset()
|
self.inner.reset()
|
||||||
}
|
}
|
||||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
|
||||||
self.inner.reconfigure_bitrate(bps)
|
|
||||||
}
|
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush()
|
self.inner.flush()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ struct EncodeApi {
|
|||||||
) -> nv::NVENCSTATUS,
|
) -> nv::NVENCSTATUS,
|
||||||
initialize_encoder:
|
initialize_encoder:
|
||||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
|
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,
|
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
|
||||||
get_encode_caps: unsafe extern "C" fn(
|
get_encode_caps: unsafe extern "C" fn(
|
||||||
*mut c_void,
|
*mut c_void,
|
||||||
@@ -189,7 +187,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
let api = EncodeApi {
|
let api = EncodeApi {
|
||||||
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
||||||
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
||||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
|
||||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||||
@@ -297,10 +294,6 @@ pub struct NvencCudaEncoder {
|
|||||||
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring.
|
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring.
|
||||||
rfi_supported: bool,
|
rfi_supported: bool,
|
||||||
custom_vbv: 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.
|
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for one loss.
|
||||||
last_rfi_range: Option<(i64, i64)>,
|
last_rfi_range: Option<(i64, i64)>,
|
||||||
}
|
}
|
||||||
@@ -368,7 +361,6 @@ impl NvencCudaEncoder {
|
|||||||
inited: false,
|
inited: false,
|
||||||
rfi_supported: false,
|
rfi_supported: false,
|
||||||
custom_vbv: false,
|
custom_vbv: false,
|
||||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
|
||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -473,11 +465,21 @@ impl NvencCudaEncoder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
|
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||||
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
|
/// Returns the session handle, or destroys it and returns the error.
|
||||||
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||||
/// retarget re-authors the exact same config with only the bitrate + derived VBV moved.
|
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||||
unsafe fn build_config(&self, enc: *mut c_void, bitrate: u64) -> Result<nv::NV_ENC_CONFIG> {
|
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?)"))?;
|
||||||
|
|
||||||
// Seed the P1 + ultra-low-latency preset config.
|
// Seed the P1 + ultra-low-latency preset config.
|
||||||
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
||||||
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
||||||
@@ -487,7 +489,7 @@ impl NvencCudaEncoder {
|
|||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(api().get_encode_preset_config_ex)(
|
if let Err(e) = (api().get_encode_preset_config_ex)(
|
||||||
enc,
|
enc,
|
||||||
self.codec_guid,
|
self.codec_guid,
|
||||||
nv::NV_ENC_PRESET_P1_GUID,
|
nv::NV_ENC_PRESET_P1_GUID,
|
||||||
@@ -495,7 +497,10 @@ impl NvencCudaEncoder {
|
|||||||
&mut preset,
|
&mut preset,
|
||||||
)
|
)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
{
|
||||||
|
let _ = (api().destroy_encoder)(enc);
|
||||||
|
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
||||||
|
}
|
||||||
let mut cfg = preset.presetCfg;
|
let mut cfg = preset.presetCfg;
|
||||||
|
|
||||||
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
|
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
|
||||||
@@ -506,9 +511,7 @@ impl NvencCudaEncoder {
|
|||||||
cfg.rcParams.averageBitRate = bps;
|
cfg.rcParams.averageBitRate = bps;
|
||||||
cfg.rcParams.maxBitRate = bps;
|
cfg.rcParams.maxBitRate = bps;
|
||||||
if self.custom_vbv {
|
if self.custom_vbv {
|
||||||
// ~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) as u32;
|
||||||
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.vbvBufferSize = vbv;
|
||||||
cfg.rcParams.vbvInitialDelay = vbv;
|
cfg.rcParams.vbvInitialDelay = vbv;
|
||||||
}
|
}
|
||||||
@@ -618,18 +621,7 @@ 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 {
|
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||||
encodeGUID: self.codec_guid,
|
encodeGUID: self.codec_guid,
|
||||||
@@ -642,36 +634,10 @@ impl NvencCudaEncoder {
|
|||||||
frameRateNum: self.fps,
|
frameRateNum: self.fps,
|
||||||
frameRateDen: 1,
|
frameRateDen: 1,
|
||||||
enablePTD: 1,
|
enablePTD: 1,
|
||||||
encodeConfig: cfg,
|
encodeConfig: &mut cfg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
init.set_splitEncodeMode(split_mode);
|
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() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
Ok(()) => Ok(enc),
|
Ok(()) => Ok(enc),
|
||||||
@@ -784,10 +750,6 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.encoder = enc;
|
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.
|
// Output bitstream pool.
|
||||||
for _ in 0..POOL {
|
for _ in 0..POOL {
|
||||||
@@ -1152,50 +1114,6 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
true
|
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<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||||
}
|
}
|
||||||
@@ -1351,68 +1269,6 @@ mod tests {
|
|||||||
println!("nvenc_cuda 4:4:4 smoke: {aus} AUs, caps.chroma_444=true");
|
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).
|
/// 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
|
/// 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
|
/// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA
|
||||||
|
|||||||
@@ -192,12 +192,6 @@ pub struct VulkanVideoEncoder {
|
|||||||
// --- rate control (CBR), rebuilt-safe ---
|
// --- rate control (CBR), rebuilt-safe ---
|
||||||
bitrate: u64,
|
bitrate: u64,
|
||||||
fps: u32,
|
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 ---
|
// --- state ---
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -660,7 +654,6 @@ impl VulkanVideoEncoder {
|
|||||||
compute_pool,
|
compute_pool,
|
||||||
bitrate,
|
bitrate,
|
||||||
fps,
|
fps,
|
||||||
pending_bitrate: None,
|
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
render_w: rw,
|
render_w: rw,
|
||||||
@@ -908,15 +901,6 @@ impl VulkanVideoEncoder {
|
|||||||
let nv12_view = self.frames[slot].nv12_view;
|
let nv12_view = self.frames[slot].nv12_view;
|
||||||
|
|
||||||
// ---- 1. decide frame type + reference (RFI) ----
|
// ---- 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 is_idr = self.first_frame || self.force_kf;
|
||||||
let mut ref_slot = self.prev_slot;
|
let mut ref_slot = self.prev_slot;
|
||||||
let mut recovery = false;
|
let mut recovery = false;
|
||||||
@@ -1218,15 +1202,6 @@ impl VulkanVideoEncoder {
|
|||||||
self.enc_count += 1;
|
self.enc_count += 1;
|
||||||
self.first_frame = false;
|
self.first_frame = false;
|
||||||
self.force_kf = 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,27 +1436,6 @@ impl VulkanVideoEncoder {
|
|||||||
);
|
);
|
||||||
ctrl.p_next = rc_ptr;
|
ctrl.p_next = rc_ptr;
|
||||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
(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());
|
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||||
@@ -1720,25 +1674,6 @@ impl VulkanVideoEncoder {
|
|||||||
);
|
);
|
||||||
ctrl.p_next = rc_ptr;
|
ctrl.p_next = rc_ptr;
|
||||||
(self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl);
|
(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());
|
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||||
@@ -1897,16 +1832,6 @@ impl Encoder for VulkanVideoEncoder {
|
|||||||
self.poc = 0;
|
self.poc = 0;
|
||||||
self.slot_wire.iter_mut().for_each(|s| *s = -1);
|
self.slot_wire.iter_mut().for_each(|s| *s = -1);
|
||||||
self.slot_poc.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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1328,14 +1328,6 @@ impl AmfEncoder {
|
|||||||
!ltr_disabled() && matches!(self.codec, Codec::H264 | Codec::H265)
|
!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
|
/// 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()`
|
/// 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).
|
/// re-`Init` (Terminate does not guarantee property retention across every driver).
|
||||||
@@ -1365,12 +1357,14 @@ impl AmfEncoder {
|
|||||||
true,
|
true,
|
||||||
)?;
|
)?;
|
||||||
// ~1-frame VBV (PUNKTFUNK_VBV_FRAMES override, same knob as the ffmpeg path).
|
// ~1-frame VBV (PUNKTFUNK_VBV_FRAMES override, same knob as the ffmpeg path).
|
||||||
set_prop(
|
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||||
comp,
|
.ok()
|
||||||
p.vbv_size,
|
.and_then(|s| s.parse::<f32>().ok())
|
||||||
AmfVariant::from_i64(self.vbv_bits(self.bitrate_bps)),
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
false,
|
.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.enforce_hrd, AmfVariant::from_bool(true), false)?;
|
set_prop(comp, p.enforce_hrd, AmfVariant::from_bool(true), false)?;
|
||||||
set_prop(comp, p.filler_data, AmfVariant::from_bool(false), false)?;
|
set_prop(comp, p.filler_data, AmfVariant::from_bool(false), false)?;
|
||||||
// Latency-first quality; low-latency submission mode (optional — newer VCN/drivers).
|
// Latency-first quality; low-latency submission mode (optional — newer VCN/drivers).
|
||||||
@@ -2505,47 +2499,6 @@ impl Encoder for AmfEncoder {
|
|||||||
true
|
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<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
let Some(inner) = self.inner.as_mut() else {
|
let Some(inner) = self.inner.as_mut() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ struct EncodeApi {
|
|||||||
) -> nv::NVENCSTATUS,
|
) -> nv::NVENCSTATUS,
|
||||||
initialize_encoder:
|
initialize_encoder:
|
||||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
|
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,
|
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
|
||||||
get_encode_caps: unsafe extern "C" fn(
|
get_encode_caps: unsafe extern "C" fn(
|
||||||
*mut c_void,
|
*mut c_void,
|
||||||
@@ -209,7 +207,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
Ok(EncodeApi {
|
Ok(EncodeApi {
|
||||||
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
|
||||||
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
|
||||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
|
||||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||||
@@ -457,11 +454,6 @@ pub struct NvencD3d11Encoder {
|
|||||||
/// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
|
/// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
|
||||||
rfi_supported: bool,
|
rfi_supported: bool,
|
||||||
custom_vbv: 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
|
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
|
||||||
/// loss event (the client resends until it sees recovery).
|
/// loss event (the client resends until it sees recovery).
|
||||||
last_rfi_range: Option<(i64, i64)>,
|
last_rfi_range: Option<(i64, i64)>,
|
||||||
@@ -534,8 +526,6 @@ impl NvencD3d11Encoder {
|
|||||||
inited: false,
|
inited: false,
|
||||||
rfi_supported: false,
|
rfi_supported: false,
|
||||||
custom_vbv: 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,
|
last_rfi_range: None,
|
||||||
init_device: ptr::null_mut(),
|
init_device: ptr::null_mut(),
|
||||||
session_units: 0,
|
session_units: 0,
|
||||||
@@ -689,11 +679,28 @@ impl NvencD3d11Encoder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
|
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||||
/// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
|
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||||
/// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
|
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||||
/// retarget re-authors the exact same config with only the bitrate + derived VBV moved.
|
unsafe fn try_open_session(
|
||||||
unsafe fn build_config(&self, enc: *mut c_void, bitrate: u64) -> Result<nv::NV_ENC_CONFIG> {
|
&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?)"))?;
|
||||||
|
|
||||||
// Seed the P1 + ultra-low-latency preset config.
|
// Seed the P1 + ultra-low-latency preset config.
|
||||||
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
let mut preset = nv::NV_ENC_PRESET_CONFIG {
|
||||||
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
version: nv::NV_ENC_PRESET_CONFIG_VER,
|
||||||
@@ -703,7 +710,7 @@ impl NvencD3d11Encoder {
|
|||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(api().get_encode_preset_config_ex)(
|
if let Err(e) = (api().get_encode_preset_config_ex)(
|
||||||
enc,
|
enc,
|
||||||
self.codec_guid,
|
self.codec_guid,
|
||||||
nv::NV_ENC_PRESET_P1_GUID,
|
nv::NV_ENC_PRESET_P1_GUID,
|
||||||
@@ -711,7 +718,10 @@ impl NvencD3d11Encoder {
|
|||||||
&mut preset,
|
&mut preset,
|
||||||
)
|
)
|
||||||
.nv_ok()
|
.nv_ok()
|
||||||
.map_err(|e| anyhow!("get_encode_preset_config_ex: {e:?}"))?;
|
{
|
||||||
|
let _ = (api().destroy_encoder)(enc);
|
||||||
|
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
|
||||||
|
}
|
||||||
let mut cfg = preset.presetCfg;
|
let mut cfg = preset.presetCfg;
|
||||||
|
|
||||||
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
|
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
|
||||||
@@ -724,9 +734,7 @@ impl NvencD3d11Encoder {
|
|||||||
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
|
// 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).
|
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
|
||||||
if self.custom_vbv {
|
if self.custom_vbv {
|
||||||
// ~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) as u32;
|
||||||
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.vbvBufferSize = vbv;
|
||||||
cfg.rcParams.vbvInitialDelay = vbv;
|
cfg.rcParams.vbvInitialDelay = vbv;
|
||||||
}
|
}
|
||||||
@@ -887,19 +895,7 @@ 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 {
|
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||||
encodeGUID: self.codec_guid,
|
encodeGUID: self.codec_guid,
|
||||||
@@ -915,44 +911,11 @@ impl NvencD3d11Encoder {
|
|||||||
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
|
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
|
||||||
// instead of `lock_bitstream` blocking the submit thread.
|
// instead of `lock_bitstream` blocking the submit thread.
|
||||||
enableEncodeAsync: enable_async as u32,
|
enableEncodeAsync: enable_async as u32,
|
||||||
encodeConfig: cfg,
|
encodeConfig: &mut cfg,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||||
init.set_splitEncodeMode(split_mode);
|
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() {
|
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||||
Ok(()) => Ok(enc),
|
Ok(()) => Ok(enc),
|
||||||
@@ -1106,12 +1069,6 @@ impl NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.encoder = enc;
|
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
|
// 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
|
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||||
// mode (a split session occupies one hardware session per engine).
|
// mode (a split session occupies one hardware session per engine).
|
||||||
@@ -1662,55 +1619,6 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
true
|
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<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||||
}
|
}
|
||||||
@@ -1966,126 +1874,6 @@ 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
|
/// 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
|
/// 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
|
/// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
|
||||||
|
|||||||
@@ -473,11 +473,6 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/dualsense.rs"]
|
#[path = "inject/linux/dualsense.rs"]
|
||||||
pub mod dualsense;
|
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`])
|
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
@@ -487,6 +482,11 @@ pub mod dualsense_proto;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/windows/dualsense_windows.rs"]
|
#[path = "inject/windows/dualsense_windows.rs"]
|
||||||
pub mod dualsense_windows;
|
pub mod dualsense_windows;
|
||||||
|
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
||||||
|
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
||||||
|
pub mod dualsense_edge_windows;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/dualshock4.rs"]
|
#[path = "inject/linux/dualshock4.rs"]
|
||||||
pub mod dualshock4;
|
pub mod dualshock4;
|
||||||
@@ -527,11 +527,15 @@ pub mod pad_slots;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_controller.rs"]
|
#[path = "inject/linux/steam_controller.rs"]
|
||||||
pub mod steam_controller;
|
pub mod steam_controller;
|
||||||
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
|
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
||||||
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
#[cfg(target_os = "linux")]
|
||||||
#[cfg(target_os = "windows")]
|
#[path = "inject/linux/switch_pro.rs"]
|
||||||
#[path = "inject/windows/steam_deck_windows.rs"]
|
pub mod switch_pro;
|
||||||
pub mod steam_deck_windows;
|
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
||||||
|
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/proto/switch_proto.rs"]
|
||||||
|
pub mod switch_proto;
|
||||||
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||||
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
||||||
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
||||||
@@ -539,9 +543,8 @@ pub mod steam_deck_windows;
|
|||||||
#[path = "inject/linux/steam_gadget.rs"]
|
#[path = "inject/linux/steam_gadget.rs"]
|
||||||
pub mod steam_gadget;
|
pub mod steam_gadget;
|
||||||
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck
|
||||||
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
|
/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]).
|
||||||
/// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
|
#[cfg(target_os = "linux")]
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
||||||
#[path = "inject/proto/steam_proto.rs"]
|
#[path = "inject/proto/steam_proto.rs"]
|
||||||
pub mod steam_proto;
|
pub mod steam_proto;
|
||||||
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
||||||
@@ -556,15 +559,6 @@ pub mod steam_remap;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_usbip.rs"]
|
#[path = "inject/linux/steam_usbip.rs"]
|
||||||
pub mod steam_usbip;
|
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
|
/// 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
|
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||||
|
|
||||||
use super::dualsense_proto::{
|
use super::dualsense_proto::{
|
||||||
ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
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_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
|
||||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC,
|
||||||
|
DUALSENSE_RDESC,
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -33,8 +34,6 @@ const UHID_GET_REPORT: u32 = 9;
|
|||||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||||
const UHID_CREATE2: u32 = 11;
|
const UHID_CREATE2: u32 = 11;
|
||||||
const UHID_INPUT2: u32 = 12;
|
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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||||
const BUS_USB: u16 = 0x03;
|
const BUS_USB: u16 = 0x03;
|
||||||
@@ -103,14 +102,10 @@ impl DualSensePad {
|
|||||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||||
})?;
|
})?;
|
||||||
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
||||||
ds.send_create2(index, id)
|
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?;
|
||||||
.context("UHID_CREATE2 DualSense")?;
|
|
||||||
Ok(ds)
|
Ok(ds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<()> {
|
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
@@ -166,25 +161,15 @@ impl DualSensePad {
|
|||||||
UHID_GET_REPORT => {
|
UHID_GET_REPORT => {
|
||||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
// 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 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] {
|
let data: &[u8] = match ev[8] {
|
||||||
0x05 => DS_FEATURE_CALIBRATION,
|
0x05 => DS_FEATURE_CALIBRATION,
|
||||||
0x09 => &pairing,
|
0x09 => DS_FEATURE_PAIRING,
|
||||||
0x20 => DS_FEATURE_FIRMWARE,
|
0x20 => DS_FEATURE_FIRMWARE,
|
||||||
_ => &[],
|
_ => &[],
|
||||||
};
|
};
|
||||||
let _ = self.reply_get_report(id, data);
|
let _ = self.reply_get_report(id, data);
|
||||||
}
|
}
|
||||||
UHID_SET_REPORT => {
|
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||||
// 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
|
fb
|
||||||
@@ -204,18 +189,6 @@ impl DualSensePad {
|
|||||||
.context("write UHID_GET_REPORT_REPLY")?;
|
.context("write UHID_GET_REPORT_REPLY")?;
|
||||||
Ok(())
|
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 {
|
impl Drop for DualSensePad {
|
||||||
@@ -396,257 +369,3 @@ impl PadProto for DsEdgeLinuxProto {
|
|||||||
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
|
/// 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.
|
/// client declares for a paddle-bearing physical controller.
|
||||||
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
|
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:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ const UHID_GET_REPORT: u32 = 9;
|
|||||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||||
const UHID_CREATE2: u32 = 11;
|
const UHID_CREATE2: u32 = 11;
|
||||||
const UHID_INPUT2: u32 = 12;
|
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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||||
const BUS_USB: u16 = 0x03;
|
const BUS_USB: u16 = 0x03;
|
||||||
@@ -48,17 +46,6 @@ const BUS_USB: u16 = 0x03;
|
|||||||
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
|
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,
|
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]
|
#[rustfmt::skip]
|
||||||
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
||||||
0x02,
|
0x02,
|
||||||
@@ -217,9 +204,9 @@ impl DualShock4Pad {
|
|||||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
|
/// 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
|
/// 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
|
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
|
||||||
/// [`Ds4Feedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
|
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake
|
||||||
/// init handshake completes.
|
/// completes.
|
||||||
pub fn service(&mut self, pad: u8) -> Ds4Feedback {
|
pub fn service(&mut self) -> Ds4Feedback {
|
||||||
let mut fb = Ds4Feedback::default();
|
let mut fb = Ds4Feedback::default();
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
while let Ok(n) = self.fd.read(&mut ev) {
|
while let Ok(n) = self.fd.read(&mut ev) {
|
||||||
@@ -236,22 +223,15 @@ impl DualShock4Pad {
|
|||||||
UHID_GET_REPORT => {
|
UHID_GET_REPORT => {
|
||||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
// 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 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] {
|
let data: &[u8] = match ev[8] {
|
||||||
0x12 => &pairing,
|
0x12 => DS4_FEATURE_PAIRING,
|
||||||
0x02 => DS4_FEATURE_CALIBRATION,
|
0x02 => DS4_FEATURE_CALIBRATION,
|
||||||
0xA3 => DS4_FEATURE_FIRMWARE,
|
0xA3 => DS4_FEATURE_FIRMWARE,
|
||||||
_ => &[],
|
_ => &[],
|
||||||
};
|
};
|
||||||
let _ = self.reply_get_report(id, data);
|
let _ = self.reply_get_report(id, data);
|
||||||
}
|
}
|
||||||
UHID_SET_REPORT => {
|
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||||
// 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
|
fb
|
||||||
@@ -271,18 +251,6 @@ impl DualShock4Pad {
|
|||||||
.context("write UHID_GET_REPORT_REPLY")?;
|
.context("write UHID_GET_REPORT_REPLY")?;
|
||||||
Ok(())
|
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 {
|
impl Drop for DualShock4Pad {
|
||||||
@@ -370,7 +338,7 @@ impl PadProto for Ds4LinuxProto {
|
|||||||
/// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
|
/// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
|
||||||
/// triggers).
|
/// triggers).
|
||||||
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
||||||
let fb = pad.service(idx);
|
let fb = pad.service();
|
||||||
PadFeedback {
|
PadFeedback {
|
||||||
rumble: fb.rumble,
|
rumble: fb.rumble,
|
||||||
hidout: fb
|
hidout: fb
|
||||||
@@ -407,16 +375,4 @@ mod tests {
|
|||||||
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
||||||
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,105 +625,3 @@ impl GamepadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -730,74 +730,4 @@ mod tests {
|
|||||||
"device not torn down on drop"
|
"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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,7 @@ impl SwitchProPad {
|
|||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
// union (uhid_create2_req) starts at byte 4.
|
// union (uhid_create2_req) starts at byte 4.
|
||||||
put_cstr(
|
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128]
|
||||||
&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, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
|
||||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[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[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||||
@@ -128,13 +123,7 @@ impl SwitchProPad {
|
|||||||
let reply = match id {
|
let reply = match id {
|
||||||
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
||||||
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
||||||
0x02 => build_subcmd_reply(
|
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))),
|
||||||
&st,
|
|
||||||
self.timer,
|
|
||||||
0x82,
|
|
||||||
id,
|
|
||||||
&device_info_payload(&switch_mac(self.index)),
|
|
||||||
),
|
|
||||||
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
|
// 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
|
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
|
||||||
// its defaults instead of stalling through 2 × 1 s timeouts.
|
// its defaults instead of stalling through 2 × 1 s timeouts.
|
||||||
@@ -145,11 +134,7 @@ impl SwitchProPad {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let len = args.get(4).copied().unwrap_or(0);
|
let len = args.get(4).copied().unwrap_or(0);
|
||||||
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
||||||
tracing::debug!(
|
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill");
|
||||||
addr = format!("{addr:#x}"),
|
|
||||||
len,
|
|
||||||
"unmapped SPI read — zero fill"
|
|
||||||
);
|
|
||||||
let mut p = Vec::with_capacity(5 + len as usize);
|
let mut p = Vec::with_capacity(5 + len as usize);
|
||||||
p.extend_from_slice(&addr.to_le_bytes());
|
p.extend_from_slice(&addr.to_le_bytes());
|
||||||
p.push(len);
|
p.push(len);
|
||||||
|
|||||||
@@ -42,18 +42,6 @@ pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build
|
|||||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The pairing reply (report `0x09`) for wire pad `pad`: [`DS_FEATURE_PAIRING`] with the MAC's low
|
|
||||||
/// octet offset by the pad index. The MAC must be **unique per pad**: `hid-playstation` adopts it
|
|
||||||
/// as the HID `uniq` (replacing whatever uniq the device was created with), and SDL/Steam dedup
|
|
||||||
/// controllers by that serial — with identical MACs a second virtual pad reads as the *first* pad
|
|
||||||
/// re-appearing over another transport and is merged/ignored.
|
|
||||||
pub fn ds_pairing_reply(pad: u8) -> [u8; 20] {
|
|
||||||
let mut r = [0u8; 20];
|
|
||||||
r.copy_from_slice(DS_FEATURE_PAIRING);
|
|
||||||
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
|
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
|
||||||
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
|
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
@@ -935,16 +923,4 @@ mod tests {
|
|||||||
assert!(fb.rumble.is_none());
|
assert!(fb.rumble.is_none());
|
||||||
assert!(fb.hidout.is_empty());
|
assert!(fb.hidout.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
|
|
||||||
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
|
|
||||||
#[test]
|
|
||||||
fn pairing_reply_mac_is_per_pad() {
|
|
||||||
assert_eq!(ds_pairing_reply(0).as_slice(), DS_FEATURE_PAIRING);
|
|
||||||
let (a, b) = (ds_pairing_reply(1), ds_pairing_reply(2));
|
|
||||||
assert_eq!(a[0], 0x09); // report id untouched
|
|
||||||
assert_eq!(a[1], DS_FEATURE_PAIRING[1].wrapping_add(1));
|
|
||||||
assert_eq!(b[1], DS_FEATURE_PAIRING[1].wrapping_add(2));
|
|
||||||
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ pub fn sc_from_gamepad(
|
|||||||
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
||||||
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
||||||
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
||||||
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
||||||
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||||
set(
|
set(
|
||||||
&mut b,
|
&mut b,
|
||||||
@@ -444,7 +444,7 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
|||||||
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
||||||
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
||||||
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
||||||
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
||||||
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
||||||
(st.lpad_x, st.lpad_y)
|
(st.lpad_x, st.lpad_y)
|
||||||
} else {
|
} else {
|
||||||
@@ -542,16 +542,12 @@ pub fn deck_unit_id(index: u8) -> u32 {
|
|||||||
0x5046_0000 | index as u32
|
0x5046_0000 | index as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
|
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`; Steam rejects
|
||||||
/// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
|
/// a too-short/oddly-formatted one as "Invalid or missing unit serial number" and substitutes its
|
||||||
/// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
|
/// own — benign, but we present a clean 12-char one). Derived from [`deck_unit_id`] so the `0xAE`
|
||||||
/// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
|
/// serial reply and the `0x83` unit-id attrs stay consistent.
|
||||||
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
|
|
||||||
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
|
|
||||||
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
|
|
||||||
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
|
|
||||||
pub fn deck_serial(index: u8) -> String {
|
pub fn deck_serial(index: u8) -> String {
|
||||||
format!("FVPF{:08X}", deck_unit_id(index))
|
format!("PFDK{:08X}", deck_unit_id(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
||||||
@@ -828,7 +824,11 @@ mod tests {
|
|||||||
fn sc_serialize_and_mapping() {
|
fn sc_serialize_and_mapping() {
|
||||||
// Full mapping: face + grips + clicks + a deflected right stick.
|
// Full mapping: face + grips + clicks + a deflected right stick.
|
||||||
let s = sc_from_gamepad(
|
let s = sc_from_gamepad(
|
||||||
gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
|
gs::BTN_A
|
||||||
|
| gs::BTN_PADDLE1
|
||||||
|
| gs::BTN_PADDLE2
|
||||||
|
| gs::BTN_LS_CLICK
|
||||||
|
| gs::BTN_RS_CLICK,
|
||||||
1000,
|
1000,
|
||||||
-2000,
|
-2000,
|
||||||
3000,
|
3000,
|
||||||
@@ -918,7 +918,7 @@ mod tests {
|
|||||||
fn deck_feature_reply_contract() {
|
fn deck_feature_reply_contract() {
|
||||||
let serial = deck_serial(0);
|
let serial = deck_serial(0);
|
||||||
let unit_id = deck_unit_id(0);
|
let unit_id = deck_unit_id(0);
|
||||||
assert_eq!(serial, "FVPF50460000"); // 12-char alphanumeric, derived from the unit id
|
assert_eq!(serial, "PFDK50460000"); // 12-char alphanumeric, derived from the unit id
|
||||||
assert_eq!(serial.len(), 12);
|
assert_eq!(serial.len(), 12);
|
||||||
|
|
||||||
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ mod tests {
|
|||||||
fn pack12_layout() {
|
fn pack12_layout() {
|
||||||
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
||||||
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
||||||
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
||||||
let p = pack12(0xABC, 0x123);
|
let p = pack12(0xABC, 0x123);
|
||||||
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
||||||
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
||||||
|
|||||||
@@ -88,11 +88,6 @@ pub(super) struct SwDeviceProfile<'a> {
|
|||||||
pub hwid: &'a str,
|
pub hwid: &'a str,
|
||||||
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
|
||||||
pub usb_vid_pid: &'a str,
|
pub usb_vid_pid: &'a str,
|
||||||
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
|
|
||||||
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
|
|
||||||
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
|
|
||||||
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
|
|
||||||
pub usb_mi: Option<u8>,
|
|
||||||
/// Device description shown in Device Manager.
|
/// Device description shown in Device Manager.
|
||||||
pub description: &'a str,
|
pub description: &'a str,
|
||||||
}
|
}
|
||||||
@@ -131,9 +126,8 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
|
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
|
||||||
let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
|
let usb = format!("USB\\{}", p.usb_vid_pid);
|
||||||
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
|
|
||||||
let hwids = multi_sz(&[
|
let hwids = multi_sz(&[
|
||||||
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
p.hwid, // FIRST → the INF binds our UMDF driver on this id
|
||||||
usb_rev.as_str(),
|
usb_rev.as_str(),
|
||||||
@@ -303,7 +297,6 @@ impl DsWinPad {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: id.hwid,
|
hwid: id.hwid,
|
||||||
usb_vid_pid: id.usb_vid_pid,
|
usb_vid_pid: id.usb_vid_pid,
|
||||||
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
|
|
||||||
description: id.description,
|
description: id.description,
|
||||||
}) {
|
}) {
|
||||||
Ok((h, i)) => (Some(h), i),
|
Ok((h, i)) => (Some(h), i),
|
||||||
@@ -494,7 +487,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
||||||
// FIRST, magic LAST — the same publish order the session pads use.
|
// FIRST, magic LAST — the same publish order the session pads use.
|
||||||
unsafe {
|
unsafe {
|
||||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE;
|
||||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
@@ -505,9 +498,6 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_steamdeck",
|
hwid: "pf_steamdeck",
|
||||||
usb_vid_pid: "VID_28DE&PID_1205",
|
usb_vid_pid: "VID_28DE&PID_1205",
|
||||||
// The Deck's controller interface — the promotion gate the first spike run hit
|
|
||||||
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
|
||||||
usb_mi: Some(2),
|
|
||||||
description: "punktfunk Virtual Steam Deck (spike)",
|
description: "punktfunk Virtual Steam Deck (spike)",
|
||||||
})?;
|
})?;
|
||||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||||
@@ -525,8 +515,9 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
|||||||
channel.pump();
|
channel.pump();
|
||||||
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
||||||
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
||||||
let seq =
|
let seq = unsafe {
|
||||||
unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
|
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||||
|
};
|
||||||
if seq != last_out_seq {
|
if seq != last_out_seq {
|
||||||
last_out_seq = seq;
|
last_out_seq = seq;
|
||||||
let mut out = [0u8; 16];
|
let mut out = [0u8; 16];
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ impl Ds4WinPad {
|
|||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_dualshock4",
|
hwid: "pf_dualshock4",
|
||||||
usb_vid_pid: "VID_054C&PID_09CC",
|
usb_vid_pid: "VID_054C&PID_09CC",
|
||||||
usb_mi: None,
|
|
||||||
description: "punktfunk Virtual DualShock 4",
|
description: "punktfunk Virtual DualShock 4",
|
||||||
}) {
|
}) {
|
||||||
Ok((h, id)) => (Some(h), id),
|
Ok((h, id)) => (Some(h), id),
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of
|
|
||||||
//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec
|
|
||||||
//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the
|
|
||||||
//! `XInput`/rich mappers, the `0xEB` rumble parser).
|
|
||||||
//!
|
|
||||||
//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3),
|
|
||||||
//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB
|
|
||||||
//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass
|
|
||||||
//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then
|
|
||||||
//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`,
|
|
||||||
//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs +
|
|
||||||
//! trackpads + gyro + back grips through Steam's own remapping.
|
|
||||||
//!
|
|
||||||
//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via
|
|
||||||
//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's
|
|
||||||
//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape
|
|
||||||
//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux
|
|
||||||
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
|
|
||||||
|
|
||||||
use super::dualsense_windows::{
|
|
||||||
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
|
|
||||||
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
|
||||||
};
|
|
||||||
use super::gamepad_raii::PadChannel;
|
|
||||||
use super::steam_proto::{
|
|
||||||
neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN,
|
|
||||||
};
|
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
|
||||||
use anyhow::Result;
|
|
||||||
use punktfunk_core::quic::RichInput;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
|
||||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
|
||||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
|
||||||
pub struct DeckWinPad {
|
|
||||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
|
||||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
|
||||||
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
|
|
||||||
channel: PadChannel,
|
|
||||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
|
||||||
attach: super::gamepad_raii::DriverAttach,
|
|
||||||
seq: u32,
|
|
||||||
last_out_seq: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeckWinPad {
|
|
||||||
/// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the
|
|
||||||
/// neutral Deck frame + the magic LAST, then spawn the `pf_deck_<index>` devnode with the
|
|
||||||
/// `MI_02` USB identity Steam's promotion gate requires.
|
|
||||||
fn open(index: u8) -> Result<DeckWinPad> {
|
|
||||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
|
||||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
|
||||||
let base = channel.data_base();
|
|
||||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
|
||||||
unsafe {
|
|
||||||
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
|
|
||||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
|
||||||
std::ptr::write_unaligned(
|
|
||||||
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
|
|
||||||
neutral_deck_report(),
|
|
||||||
);
|
|
||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
|
||||||
}
|
|
||||||
let inst = format!("pf_deck_{index}");
|
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
|
||||||
instance: &inst,
|
|
||||||
container_index: index,
|
|
||||||
hwid: "pf_steamdeck",
|
|
||||||
usb_vid_pid: "VID_28DE&PID_1205",
|
|
||||||
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
|
||||||
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
|
||||||
// spike's run-1 failure).
|
|
||||||
usb_mi: Some(2),
|
|
||||||
description: "punktfunk Virtual Steam Deck",
|
|
||||||
}) {
|
|
||||||
Ok((h, i)) => (Some(h), i),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
|
||||||
(None, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
|
||||||
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
|
|
||||||
// it for descriptors, or the pad would enumerate with the default DualSense identity.
|
|
||||||
channel.deliver_eager(Duration::from_millis(1500));
|
|
||||||
Ok(DeckWinPad {
|
|
||||||
_sw,
|
|
||||||
channel,
|
|
||||||
attach: super::gamepad_raii::DriverAttach::new(
|
|
||||||
"pf_steamdeck",
|
|
||||||
"pf_dualsense.inf", // one driver package serves every identity
|
|
||||||
"C:\\Users\\Public\\pfds-driver.log",
|
|
||||||
boot_name,
|
|
||||||
instance_id,
|
|
||||||
),
|
|
||||||
seq: 0,
|
|
||||||
last_out_seq: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize `st` into the Deck state frame and publish it to the section's input slot.
|
|
||||||
fn write_state(&mut self, st: &SteamState) {
|
|
||||||
self.seq = self.seq.wrapping_add(1);
|
|
||||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
|
||||||
serialize_deck_state(&mut r, st, self.seq);
|
|
||||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
r.as_ptr(),
|
|
||||||
self.channel.data_base().add(OFF_INPUT),
|
|
||||||
r.len(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
|
|
||||||
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
|
|
||||||
/// ticks the sealed-channel delivery and the driver-attach health watcher.
|
|
||||||
fn service(&mut self) -> Option<(u16, u16)> {
|
|
||||||
self.channel.pump();
|
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
|
||||||
let proto = unsafe {
|
|
||||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
|
||||||
};
|
|
||||||
self.attach.observe(proto);
|
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
|
||||||
let seq = unsafe {
|
|
||||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
|
||||||
};
|
|
||||||
if seq == self.last_out_seq {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.last_out_seq = seq;
|
|
||||||
let mut out = [0u8; 64];
|
|
||||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(
|
|
||||||
self.channel.data_base().add(OFF_OUTPUT),
|
|
||||||
out.as_mut_ptr(),
|
|
||||||
64,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
parse_steam_output(&out).rumble
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel
|
|
||||||
/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend,
|
|
||||||
/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup)
|
|
||||||
/// lives in [`UhidManager`].
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct DeckWinProto;
|
|
||||||
|
|
||||||
impl PadProto for DeckWinProto {
|
|
||||||
type Pad = DeckWinPad;
|
|
||||||
type State = SteamState;
|
|
||||||
const LABEL: &'static str = "Steam Deck/Windows";
|
|
||||||
const DEVICE: &'static str = "Steam Deck";
|
|
||||||
const CREATE_HINT: &'static str =
|
|
||||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
|
||||||
|
|
||||||
fn open(&mut self, idx: u8) -> Result<DeckWinPad> {
|
|
||||||
let p = DeckWinPad::open(idx)?;
|
|
||||||
tracing::info!(
|
|
||||||
index = idx,
|
|
||||||
"virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)"
|
|
||||||
);
|
|
||||||
Ok(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn neutral(&self) -> SteamState {
|
|
||||||
SteamState::neutral()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion +
|
|
||||||
/// pad clicks arrive separately and must survive a button-only frame) — identical to the
|
|
||||||
/// Linux `SteamProto::merge_frame`.
|
|
||||||
fn merge_frame(
|
|
||||||
&self,
|
|
||||||
prev: &SteamState,
|
|
||||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
|
||||||
) -> SteamState {
|
|
||||||
use super::steam_proto::btn;
|
|
||||||
let mut s = SteamState::from_gamepad(
|
|
||||||
f.buttons,
|
|
||||||
f.ls_x,
|
|
||||||
f.ls_y,
|
|
||||||
f.rs_x,
|
|
||||||
f.rs_y,
|
|
||||||
f.left_trigger,
|
|
||||||
f.right_trigger,
|
|
||||||
);
|
|
||||||
s.rpad_x = prev.rpad_x;
|
|
||||||
s.rpad_y = prev.rpad_y;
|
|
||||||
s.lpad_x = prev.lpad_x;
|
|
||||||
s.lpad_y = prev.lpad_y;
|
|
||||||
s.gyro = prev.gyro;
|
|
||||||
s.accel = prev.accel;
|
|
||||||
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
|
|
||||||
s.lpad_click = prev.lpad_click;
|
|
||||||
s.rpad_click = prev.rpad_click;
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
|
||||||
st.apply_rich(rich);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) {
|
|
||||||
pad.write_state(st);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The
|
|
||||||
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
|
|
||||||
/// `hidout` stays empty — parity with the Linux backend.
|
|
||||||
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
|
|
||||||
PadFeedback {
|
|
||||||
rumble: pad.service(),
|
|
||||||
hidout: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux
|
|
||||||
/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as
|
|
||||||
/// the other Windows pad managers.
|
|
||||||
pub type SteamDeckWindowsManager = UhidManager<DeckWinProto>;
|
|
||||||
@@ -429,13 +429,10 @@ fn real_main() -> Result<()> {
|
|||||||
let xbox = args.iter().any(|a| a == "--xbox");
|
let xbox = args.iter().any(|a| a == "--xbox");
|
||||||
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||||
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||||
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
|
// report byte 10 (0x80|0x40) next to Cross.
|
||||||
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
|
|
||||||
let edge = args.iter().any(|a| a == "--edge");
|
let edge = args.iter().any(|a| a == "--edge");
|
||||||
let deck = args.iter().any(|a| a == "--deck");
|
let extra_buttons: u32 = if edge {
|
||||||
let extra_buttons: u32 = if edge || deck {
|
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||||
punktfunk_core::input::gamepad::BTN_PADDLE1
|
|
||||||
| punktfunk_core::input::gamepad::BTN_PADDLE2
|
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -537,11 +534,6 @@ fn real_main() -> Result<()> {
|
|||||||
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||||
"DualSense Edge"
|
"DualSense Edge"
|
||||||
);
|
);
|
||||||
} else if deck {
|
|
||||||
drive!(
|
|
||||||
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
|
||||||
"Steam Deck"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
drive!(
|
drive!(
|
||||||
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||||
|
|||||||
@@ -1754,8 +1754,7 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
|||||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||||
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||||
/// minidriver (device-type 0/2/1).
|
/// minidriver (device-type 0/2/1).
|
||||||
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
|
/// - Steam Deck — Linux UHID `hid-steam`.
|
||||||
/// (device-type 3, Steam-Input-promoted).
|
|
||||||
///
|
///
|
||||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||||
/// constructs a manager the build lacks.
|
/// constructs a manager the build lacks.
|
||||||
@@ -1788,8 +1787,6 @@ struct Pads {
|
|||||||
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pads {
|
impl Pads {
|
||||||
@@ -1825,8 +1822,6 @@ impl Pads {
|
|||||||
dualsense_edge_win: None,
|
dualsense_edge_win: None,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: None,
|
dualshock4_win: None,
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
steamdeck_win: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1929,11 +1924,6 @@ impl Pads {
|
|||||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||||
)
|
)
|
||||||
.handle(ev),
|
.handle(ev),
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
GamepadPref::SteamDeck => self
|
|
||||||
.steamdeck_win
|
|
||||||
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
|
|
||||||
.handle(ev),
|
|
||||||
_ => self
|
_ => self
|
||||||
.xbox360
|
.xbox360
|
||||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||||
@@ -2016,12 +2006,6 @@ impl Pads {
|
|||||||
m.apply_rich(rich)
|
m.apply_rich(rich)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
GamepadPref::SteamDeck => {
|
|
||||||
if let Some(m) = &mut self.steamdeck_win {
|
|
||||||
m.apply_rich(rich)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2073,9 +2057,6 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
if let Some(m) = &mut self.steamdeck_win {
|
|
||||||
m.pump(&mut rumble, &mut hidout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2118,9 +2099,6 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
if let Some(m) = &mut self.steamdeck_win {
|
|
||||||
m.heartbeat(gap);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2805,10 +2783,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
|||||||
// are the N4 spike).
|
// are the N4 spike).
|
||||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||||
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||||
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
|
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
||||||
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
|
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
||||||
// gyro + back grips, replacing the old fold to DualSense.
|
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
||||||
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
|
// all of that silently.
|
||||||
|
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
||||||
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||||
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||||
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||||
@@ -2865,7 +2844,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
|||||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||||
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||||
/// vhci path as belt and braces.
|
/// vhci path as belt and braces.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn physical_steam_controller_present() -> bool {
|
fn physical_steam_controller_present() -> bool {
|
||||||
@@ -2877,7 +2856,7 @@ fn physical_steam_controller_present() -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if std::fs::read_to_string(e.path().join("uevent"))
|
if std::fs::read_to_string(e.path().join("uevent"))
|
||||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK")))
|
||||||
{
|
{
|
||||||
return false; // one of our own virtual Decks
|
return false; // one of our own virtual Decks
|
||||||
}
|
}
|
||||||
@@ -3224,18 +3203,13 @@ fn service_probes(
|
|||||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||||
/// only the OVERFLOW beyond that is spread across ~90% of the time to `deadline` in ADAPTIVE
|
/// only the OVERFLOW beyond that is spread in 16-packet chunks across ~90% of the time to
|
||||||
/// chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment cap) once
|
/// `deadline`. So a normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added
|
||||||
/// the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace instead
|
/// latency, while a genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping
|
||||||
/// of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
|
/// the freeze fix exactly where it's needed (an unpaced line-rate burst overruns the kernel tx
|
||||||
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
|
/// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||||
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
|
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
|
||||||
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
|
/// this is never slower than unpaced.
|
||||||
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
|
|
||||||
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
|
|
||||||
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
|
|
||||||
/// until the next keyframe). With no slack (encode ≈ interval) the budget collapses to 0 and
|
|
||||||
/// even the overflow goes out immediately, so this is never slower than unpaced.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn paced_submit(
|
fn paced_submit(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
@@ -3244,7 +3218,7 @@ fn paced_submit(
|
|||||||
flags: u32,
|
flags: u32,
|
||||||
frame_index: u32,
|
frame_index: u32,
|
||||||
deadline: std::time::Instant,
|
deadline: std::time::Instant,
|
||||||
burst_cap: Option<usize>,
|
burst_cap: usize,
|
||||||
) -> Result<PaceStat> {
|
) -> Result<PaceStat> {
|
||||||
let wires = session
|
let wires = session
|
||||||
.seal_frame_at(data, pts_ns, flags, frame_index)
|
.seal_frame_at(data, pts_ns, flags, frame_index)
|
||||||
@@ -3252,10 +3226,9 @@ fn paced_submit(
|
|||||||
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||||
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
||||||
crate::send_pacing::inject_video_drop(&mut refs);
|
crate::send_pacing::inject_video_drop(&mut refs);
|
||||||
let wire_bytes: usize = refs.iter().map(|p| p.len()).sum();
|
|
||||||
let cfg = crate::send_pacing::PaceCfg {
|
let cfg = crate::send_pacing::PaceCfg {
|
||||||
burst_bytes: Some(burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024))),
|
burst_bytes: Some(burst_cap),
|
||||||
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
|
chunk: crate::send_pacing::ChunkPolicy::Fixed(16),
|
||||||
sleep_floor: std::time::Duration::from_micros(500),
|
sleep_floor: std::time::Duration::from_micros(500),
|
||||||
};
|
};
|
||||||
let result = crate::send_pacing::pace_frame(
|
let result = crate::send_pacing::pace_frame(
|
||||||
@@ -3470,7 +3443,7 @@ fn send_loop(
|
|||||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
perf: bool,
|
perf: bool,
|
||||||
burst_cap: Option<usize>,
|
burst_cap: usize,
|
||||||
fec_target: Arc<AtomicU8>,
|
fec_target: Arc<AtomicU8>,
|
||||||
stats: SendStats,
|
stats: SendStats,
|
||||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||||
@@ -3983,14 +3956,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
let _ = &launch;
|
let _ = &launch;
|
||||||
|
|
||||||
let perf = crate::config::config().perf;
|
let perf = crate::config::config().perf;
|
||||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
|
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
||||||
// immediately; only a bigger frame's overflow is spread. `None` = auto — max(128 KB, the
|
// only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default.
|
||||||
// AU's wire bytes / 4), so the burst stays a bounded fraction of high-rate frames instead
|
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||||
// of swallowing them whole (plan Phase 1.3). PUNKTFUNK_PACE_BURST_KB pins an absolute cap.
|
|
||||||
let burst_cap: Option<usize> = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
.map(|kb| kb * 1024);
|
.unwrap_or(128)
|
||||||
|
* 1024;
|
||||||
|
|
||||||
// Encode|send split: this thread captures+encodes (the GPU work) + handles reconfig, and hands
|
// Encode|send split: this thread captures+encodes (the GPU work) + handles reconfig, and hands
|
||||||
// each AU to a dedicated send thread that owns the Session and does FEC+seal+paced-send — so the
|
// each AU to a dedicated send thread that owns the Session and does FEC+seal+paced-send — so the
|
||||||
@@ -4281,67 +4253,47 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||||
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
|
// several times while we stream) and rebuild the ENCODER ONLY in place — the mode didn't
|
||||||
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
|
// change, so capture and the virtual output are untouched and the switch costs exactly the
|
||||||
// `reconfigure_bitrate` (Phase 3.2 — NVENC nvEncReconfigureEncoder / AMF dynamic props /
|
// IDR the fresh encoder opens with (the same resync discipline as a mode switch, minus the
|
||||||
// Vulkan RC control), which keeps the encoder, its reference chain and the in-flight AUs,
|
// pipeline churn). Rates arrive pre-clamped by the control task (`resolve_bitrate_kbps`).
|
||||||
// so the step costs NOTHING on the wire (no IDR, no forfeit — exactly what the Automatic
|
|
||||||
// controller's doubling climb wants). A backend that can't (libavcodec paths) or a driver
|
|
||||||
// rejection falls back to the full rebuild, which costs the IDR the fresh encoder opens
|
|
||||||
// with (the same resync discipline as a mode switch, minus the pipeline churn) and owns
|
|
||||||
// the bitrate clamping. Rates arrive pre-clamped by the control task
|
|
||||||
// (`resolve_bitrate_kbps`).
|
|
||||||
let mut want_kbps = None;
|
let mut want_kbps = None;
|
||||||
while let Ok(k) = bitrate_rx.try_recv() {
|
while let Ok(k) = bitrate_rx.try_recv() {
|
||||||
want_kbps = Some(k);
|
want_kbps = Some(k);
|
||||||
}
|
}
|
||||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer rate.
|
||||||
tracing::info!(
|
let hz = interval_hz(interval);
|
||||||
from_kbps = bitrate_kbps,
|
match crate::encode::open_video(
|
||||||
to_kbps = new_kbps,
|
plan.codec,
|
||||||
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
frame.format,
|
||||||
);
|
frame.width,
|
||||||
bitrate_kbps = new_kbps;
|
frame.height,
|
||||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
hz,
|
||||||
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
new_kbps as u64 * 1000,
|
||||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
frame.is_cuda(),
|
||||||
} else {
|
bit_depth,
|
||||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
|
plan.chroma,
|
||||||
// rate.
|
) {
|
||||||
let hz = interval_hz(interval);
|
Ok(new_enc) => {
|
||||||
match crate::encode::open_video(
|
tracing::info!(
|
||||||
plan.codec,
|
from_kbps = bitrate_kbps,
|
||||||
frame.format,
|
to_kbps = new_kbps,
|
||||||
frame.width,
|
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||||
frame.height,
|
);
|
||||||
hz,
|
enc = new_enc;
|
||||||
new_kbps as u64 * 1000,
|
bitrate_kbps = new_kbps;
|
||||||
frame.is_cuda(),
|
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||||
bit_depth,
|
// The owed AUs died with the old encoder — same bookkeeping as a mode-switch
|
||||||
plan.chroma,
|
// rebuild; the fresh encoder opens on an IDR, so anchor the IDR cooldown too.
|
||||||
) {
|
inflight.clear();
|
||||||
Ok(new_enc) => {
|
last_au_at = std::time::Instant::now();
|
||||||
tracing::info!(
|
encoder_resets = 0;
|
||||||
from_kbps = bitrate_kbps,
|
last_forced_idr = Some(std::time::Instant::now());
|
||||||
to_kbps = new_kbps,
|
}
|
||||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
Err(e) => {
|
||||||
);
|
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||||
enc = new_enc;
|
"bitrate-change encoder rebuild failed — keeping the current rate");
|
||||||
bitrate_kbps = new_kbps;
|
|
||||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
|
||||||
// The owed AUs died with the old encoder — same bookkeeping as a
|
|
||||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
|
||||||
// IDR cooldown too.
|
|
||||||
inflight.clear();
|
|
||||||
last_au_at = std::time::Instant::now();
|
|
||||||
encoder_resets = 0;
|
|
||||||
last_forced_idr = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
|
||||||
"bitrate-change encoder rebuild failed — keeping the current rate");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5421,11 +5373,11 @@ mod tests {
|
|||||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||||
|
|
||||||
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
|
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
|
||||||
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
|
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
|
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||||
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -5448,14 +5400,14 @@ mod tests {
|
|||||||
pick_gamepad(DualSenseEdge, None, false, true),
|
pick_gamepad(DualSenseEdge, None, false, true),
|
||||||
DualSenseEdge
|
DualSenseEdge
|
||||||
);
|
);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
|
assert_eq!(
|
||||||
|
pick_gamepad(Auto, Some("edge"), true, false),
|
||||||
|
DualSenseEdge
|
||||||
|
);
|
||||||
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||||
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||||
assert_eq!(
|
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
|
||||||
pick_gamepad(Auto, Some("switchpro"), true, false),
|
|
||||||
SwitchPro
|
|
||||||
);
|
|
||||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
//! deterministic-schedule tests below):
|
//! deterministic-schedule tests below):
|
||||||
//!
|
//!
|
||||||
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
|
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
|
||||||
//! overflow is paced across 90 % of the time left to the frame deadline in ADAPTIVE chunks:
|
//! overflow is paced in fixed 16-packet chunks across 90 % of the time left to the frame
|
||||||
//! 16 packets at today's rates, coarsening just enough that the per-chunk interval clears the
|
//! deadline (no slack ⇒ budget 0 ⇒ never slower than unpaced);
|
||||||
//! sleep floor (≤ 64, the GSO-segment cap) once the rate would otherwise skip every sleep —
|
|
||||||
//! so ≥1 Gbps frames still pace instead of blasting (no slack ⇒ budget 0 ⇒ never slower than
|
|
||||||
//! unpaced);
|
|
||||||
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
|
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
|
||||||
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
|
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
|
||||||
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
|
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
|
||||||
@@ -39,13 +36,6 @@ pub(crate) struct PaceStat {
|
|||||||
pub(crate) enum ChunkPolicy {
|
pub(crate) enum ChunkPolicy {
|
||||||
/// Fixed chunk size; the step count scales with the frame (native: 16).
|
/// Fixed chunk size; the step count scales with the frame (native: 16).
|
||||||
Fixed(usize),
|
Fixed(usize),
|
||||||
/// Rate-adaptive chunk size (native, plan Phase 1.2): `base` packets until the per-chunk
|
|
||||||
/// interval (`budget / steps`) would drop under the sleep floor, then the smallest chunk
|
|
||||||
/// that keeps the interval ≥ floor, capped at `max` (the 64-segment GSO super-buffer
|
|
||||||
/// limit). Zero budget (no slack — the frame blasts anyway) takes `max`: fewest syscalls
|
|
||||||
/// for the same immediate send. Decouples the syscall batch from the pace step so high
|
|
||||||
/// rates keep REAL sleeps between chunks instead of skipping every sub-floor wait.
|
|
||||||
Adaptive { base: usize, max: usize },
|
|
||||||
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
|
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
|
||||||
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
|
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
|
||||||
Bounded { min_chunk: usize, max_steps: usize },
|
Bounded { min_chunk: usize, max_steps: usize },
|
||||||
@@ -82,15 +72,8 @@ pub(crate) struct PaceSchedule {
|
|||||||
pub(crate) steps: usize,
|
pub(crate) steps: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the schedule for one frame's wire packets under `cfg`. `pace_budget` is the time
|
/// Compute the schedule for one frame's wire packets under `cfg`.
|
||||||
/// the paced overflow will spread across (resolved by the caller); only
|
pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSchedule {
|
||||||
/// [`ChunkPolicy::Adaptive`] reads it — the `Fixed`/`Bounded` schedules are budget-independent
|
|
||||||
/// (the pinned legacy planes).
|
|
||||||
pub(crate) fn schedule<T: AsRef<[u8]>>(
|
|
||||||
packets: &[T],
|
|
||||||
cfg: &PaceCfg,
|
|
||||||
pace_budget: Duration,
|
|
||||||
) -> PaceSchedule {
|
|
||||||
let burst_len = match cfg.burst_bytes {
|
let burst_len = match cfg.burst_bytes {
|
||||||
None => 0,
|
None => 0,
|
||||||
Some(cap) => {
|
Some(cap) => {
|
||||||
@@ -111,20 +94,6 @@ pub(crate) fn schedule<T: AsRef<[u8]>>(
|
|||||||
let overflow = packets.len() - burst_len;
|
let overflow = packets.len() - burst_len;
|
||||||
let (chunk, steps) = match cfg.chunk {
|
let (chunk, steps) = match cfg.chunk {
|
||||||
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
|
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
|
||||||
ChunkPolicy::Adaptive { base, max } => {
|
|
||||||
let c = if overflow == 0 {
|
|
||||||
base
|
|
||||||
} else if pace_budget.is_zero() {
|
|
||||||
max
|
|
||||||
} else {
|
|
||||||
// interval = budget/steps ≈ budget·c/overflow ≥ sleep_floor ⇔
|
|
||||||
// c ≥ overflow·floor/budget — the smallest such c, clamped to [base, max].
|
|
||||||
let c_min = (overflow as u128 * cfg.sleep_floor.as_nanos())
|
|
||||||
.div_ceil(pace_budget.as_nanos());
|
|
||||||
c_min.clamp(base as u128, max as u128) as usize
|
|
||||||
};
|
|
||||||
(c, overflow.div_ceil(c).max(1))
|
|
||||||
}
|
|
||||||
ChunkPolicy::Bounded {
|
ChunkPolicy::Bounded {
|
||||||
min_chunk,
|
min_chunk,
|
||||||
max_steps,
|
max_steps,
|
||||||
@@ -151,19 +120,7 @@ pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
|
|||||||
mut send: impl FnMut(&[T]) -> Result<(), E>,
|
mut send: impl FnMut(&[T]) -> Result<(), E>,
|
||||||
) -> Result<PaceStat, E> {
|
) -> Result<PaceStat, E> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// Resolve the pace budget up front: adaptive chunk sizing needs it before the burst
|
let sched = schedule(packets, cfg);
|
||||||
// leaves. The paced loop below still re-anchors at `pace_start` (after the burst), so the
|
|
||||||
// sleep targets are exactly the legacy math; this entry-time estimate only sizes chunks
|
|
||||||
// (it overshoots the post-burst budget by the burst's few µs — harmless, sub-floor sleeps
|
|
||||||
// are skipped anyway).
|
|
||||||
let budget_est = match budget {
|
|
||||||
PaceBudget::UntilDeadline { deadline, fraction } => deadline
|
|
||||||
.checked_duration_since(start)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.mul_f32(fraction),
|
|
||||||
PaceBudget::Fixed(d) => d,
|
|
||||||
};
|
|
||||||
let sched = schedule(packets, cfg, budget_est);
|
|
||||||
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
|
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
|
||||||
send(chunk)?;
|
send(chunk)?;
|
||||||
}
|
}
|
||||||
@@ -300,13 +257,10 @@ mod tests {
|
|||||||
let pkts = packets(n, len);
|
let pkts = packets(n, len);
|
||||||
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
|
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
|
||||||
let (split, m) = legacy(&sizes, cap);
|
let (split, m) = legacy(&sizes, cap);
|
||||||
// Two very different budgets: Fixed schedules must not read the budget at all.
|
let s = schedule(&pkts, &native_cfg(cap));
|
||||||
for budget in [Duration::ZERO, Duration::from_millis(7)] {
|
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
|
||||||
let s = schedule(&pkts, &native_cfg(cap), budget);
|
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
|
||||||
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
|
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
|
||||||
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
|
|
||||||
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,53 +276,20 @@ mod tests {
|
|||||||
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
|
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
|
||||||
let pkts = packets(n, 1024);
|
let pkts = packets(n, 1024);
|
||||||
let (chunk, steps) = legacy_pace_layout(n);
|
let (chunk, steps) = legacy_pace_layout(n);
|
||||||
// Two very different budgets: Bounded schedules must not read the budget at all.
|
let s = schedule(&pkts, &gs_cfg());
|
||||||
for budget in [Duration::ZERO, Duration::from_millis(7)] {
|
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
|
||||||
let s = schedule(&pkts, &gs_cfg(), budget);
|
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
|
||||||
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
|
assert_eq!(s.steps, steps, "n={n}: step count");
|
||||||
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
|
assert!(s.steps <= 12, "n={n}: step count bounded");
|
||||||
assert_eq!(s.steps, steps, "n={n}: step count");
|
assert!(s.chunk >= 16, "n={n}: chunk floor");
|
||||||
assert!(s.steps <= 12, "n={n}: step count bounded");
|
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
|
||||||
assert!(s.chunk >= 16, "n={n}: chunk floor");
|
|
||||||
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// The legacy test's exact anchors.
|
// The legacy test's exact anchors.
|
||||||
let s = schedule(&packets(1, 1024), &gs_cfg(), Duration::ZERO);
|
let s = schedule(&packets(1, 1024), &gs_cfg());
|
||||||
assert_eq!((s.chunk, s.steps), (16, 1));
|
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||||
let s = schedule(&packets(16, 1024), &gs_cfg(), Duration::ZERO);
|
let s = schedule(&packets(16, 1024), &gs_cfg());
|
||||||
assert_eq!((s.chunk, s.steps), (16, 1));
|
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||||
assert!(schedule(&packets(610, 1024), &gs_cfg(), Duration::ZERO).steps <= 12);
|
assert!(schedule(&packets(610, 1024), &gs_cfg()).steps <= 12);
|
||||||
}
|
|
||||||
|
|
||||||
/// The native plane's Phase-1.2 policy (plan `throughput-beyond-1gbps.md`): 16-packet
|
|
||||||
/// chunks at today's rates, coarsening only when the per-chunk interval would drop under
|
|
||||||
/// the 500 µs sleep floor, capped at the 64-segment GSO super-buffer limit; zero budget
|
|
||||||
/// (blast) takes the cap.
|
|
||||||
#[test]
|
|
||||||
fn adaptive_chunk_coarsens_with_rate() {
|
|
||||||
let cfg = PaceCfg {
|
|
||||||
burst_bytes: Some(12_000),
|
|
||||||
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
|
|
||||||
sleep_floor: Duration::from_micros(500),
|
|
||||||
};
|
|
||||||
// 210 × 1200 B: packets 0..=9 burst (cum hits 12 000 at #10), 200 overflow.
|
|
||||||
let pkts = packets(210, 1200);
|
|
||||||
// Ample budget (100 ms): a 16-packet interval is ≫ floor → base, legacy-identical.
|
|
||||||
let s = schedule(&pkts, &cfg, Duration::from_millis(100));
|
|
||||||
assert_eq!((s.burst_len, s.chunk, s.steps), (10, 16, 13));
|
|
||||||
// 2.5 ms budget: c ≥ 200 × 500 µs / 2.5 ms = 40 → exactly 40, 5 steps × 500 µs each.
|
|
||||||
let s = schedule(&pkts, &cfg, Duration::from_micros(2_500));
|
|
||||||
assert_eq!((s.chunk, s.steps), (40, 5));
|
|
||||||
// 1 ms budget: c ≥ 100 → capped at 64 (the GSO segment limit).
|
|
||||||
let s = schedule(&pkts, &cfg, Duration::from_millis(1));
|
|
||||||
assert_eq!((s.chunk, s.steps), (64, 4));
|
|
||||||
// Zero budget (no slack — the frame blasts): max chunk = fewest syscalls.
|
|
||||||
let s = schedule(&pkts, &cfg, Duration::ZERO);
|
|
||||||
assert_eq!((s.chunk, s.steps), (64, 4));
|
|
||||||
// Whole frame under the cap: no overflow → base chunk for the burst sends.
|
|
||||||
let s = schedule(&packets(5, 1200), &cfg, Duration::ZERO);
|
|
||||||
assert_eq!((s.burst_len, s.chunk, s.steps), (5, 16, 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
|
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
|
||||||
@@ -408,27 +329,6 @@ mod tests {
|
|||||||
assert_eq!(seen, vec![16, 4]);
|
assert_eq!(seen, vec![16, 4]);
|
||||||
assert!(!stat.paced);
|
assert!(!stat.paced);
|
||||||
|
|
||||||
// Native adaptive, zero budget: the burst leaves in one ≤64-packet chunk, the overflow
|
|
||||||
// in 64-packet super-chunks (the blast path takes the coarsest syscall batching).
|
|
||||||
let pkts = packets(210, 1200);
|
|
||||||
let mut seen: Vec<usize> = Vec::new();
|
|
||||||
let stat = pace_frame(
|
|
||||||
&pkts,
|
|
||||||
PaceBudget::Fixed(Duration::ZERO),
|
|
||||||
&PaceCfg {
|
|
||||||
burst_bytes: Some(12_000),
|
|
||||||
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
|
|
||||||
sleep_floor: Duration::from_micros(500),
|
|
||||||
},
|
|
||||||
|chunk| {
|
|
||||||
seen.push(chunk.len());
|
|
||||||
Ok::<(), std::io::Error>(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(seen, vec![10, 64, 64, 64, 8]);
|
|
||||||
assert!(stat.paced);
|
|
||||||
|
|
||||||
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
|
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
|
||||||
let pkts = packets(146, 1024);
|
let pkts = packets(146, 1024);
|
||||||
let mut seen: Vec<usize> = Vec::new();
|
let mut seen: Vec<usize> = Vec::new();
|
||||||
|
|||||||
@@ -252,9 +252,6 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||||
stop_autologin_sessions();
|
stop_autologin_sessions();
|
||||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
|
||||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
|
||||||
free_desktop_steam()?;
|
|
||||||
}
|
}
|
||||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||||
@@ -319,10 +316,6 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||||
stop_autologin_sessions();
|
stop_autologin_sessions();
|
||||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
|
||||||
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
|
||||||
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
|
||||||
free_desktop_steam()?;
|
|
||||||
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||||
@@ -901,96 +894,6 @@ fn stop_autologin_sessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
|
||||||
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
|
|
||||||
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
|
|
||||||
|
|
||||||
/// B1b: free Steam held by a plain **desktop** session (GNOME/KDE — e.g. a Steam the user opened
|
|
||||||
/// while streaming the desktop). [`stop_autologin_sessions`] only frees `gamescope-session-plus@*`
|
|
||||||
/// autologin units, so a desktop Steam still holds the single instance — a dedicated launch's
|
|
||||||
/// nested `steam` would just forward its URI to it and exit, gamescope would follow its child
|
|
||||||
/// down, and the client would see a black screen while the game launches invisibly on the desktop
|
|
||||||
/// (observed 2026-07-14 on a GNOME host: session-recovery restarted GDM for a desktop stream, the
|
|
||||||
/// user opened Steam there, and the next game-library launch black-screened through all 8 pipeline
|
|
||||||
/// retries). Asks that Steam to quit via `steam -shutdown` (the single-instance IPC, graceful) and
|
|
||||||
/// waits for it to exit; on timeout the spawn fails with an operator-actionable error instead of
|
|
||||||
/// the misleading no-frames retry loop. Steam instances punktfunk owns are exempt — URI forwarding
|
|
||||||
/// into a reused/kept session is the designed path, and another session's live Steam must never be
|
|
||||||
/// torn down from here.
|
|
||||||
fn free_desktop_steam() -> Result<()> {
|
|
||||||
let Some(pid) = desktop_steam_pid() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
tracing::info!(
|
|
||||||
pid,
|
|
||||||
"freeing Steam: a desktop-session Steam holds the single instance — sending `steam -shutdown`"
|
|
||||||
);
|
|
||||||
let _ = Command::new("steam")
|
|
||||||
.arg("-shutdown")
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn();
|
|
||||||
let deadline = Instant::now() + STEAM_SHUTDOWN_WAIT;
|
|
||||||
while Instant::now() < deadline {
|
|
||||||
if !pid_running(pid) {
|
|
||||||
tracing::info!(pid, "desktop Steam exited — single instance free");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
|
||||||
}
|
|
||||||
bail!(
|
|
||||||
"Steam is already running in the host's desktop session (pid {pid}) and did not exit \
|
|
||||||
within {}s of `steam -shutdown` — close Steam on the host, then launch again",
|
|
||||||
STEAM_SHUTDOWN_WAIT.as_secs()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pid of a live Steam instance running OUTSIDE anything punktfunk owns (i.e. a desktop-session
|
|
||||||
/// Steam), found via `~/.steam/steam.pid` — Steam's own single-instance marker, kept current by
|
|
||||||
/// every fresh instance. `None` when Steam isn't running, the pidfile is stale (pid dead, zombie,
|
|
||||||
/// or recycled by a non-Steam process), or the instance is punktfunk's own: a descendant of this
|
|
||||||
/// host process (a dedicated spawn's nested Steam) or inside the managed [`SESSION_UNIT`] cgroup.
|
|
||||||
fn desktop_steam_pid() -> Option<u32> {
|
|
||||||
let home = std::env::var("HOME").ok()?;
|
|
||||||
let pid = std::fs::read_to_string(format!("{home}/.steam/steam.pid"))
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok())?;
|
|
||||||
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
|
|
||||||
// Steam's own processes report comm `steam` (the ubuntu12_32 binary) or `steam.sh`; anything
|
|
||||||
// else means the pid was recycled since Steam last ran.
|
|
||||||
if !matches!(comm.trim(), "steam" | "steam.sh") || !pid_running(pid) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if descends_from(pid, std::process::id()) {
|
|
||||||
return None; // our own dedicated spawn's Steam
|
|
||||||
}
|
|
||||||
let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).unwrap_or_default();
|
|
||||||
if cgroup_is_punktfunk_owned(&cgroup) {
|
|
||||||
return None; // the host service's tree or the managed session unit
|
|
||||||
}
|
|
||||||
Some(pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Does this `/proc/<pid>/cgroup` content place the process in a punktfunk-owned unit — the host
|
|
||||||
/// service itself or the host-managed gamescope session? Desktop Steams live in desktop app scopes
|
|
||||||
/// (e.g. `app-gnome-steam-<pid>.scope`) instead. Pure + unit-tested.
|
|
||||||
fn cgroup_is_punktfunk_owned(cgroup: &str) -> bool {
|
|
||||||
cgroup.contains("punktfunk-host.service") || cgroup.contains(&format!("{SESSION_UNIT}.service"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is `pid` alive and not a zombie? (A zombie keeps its `/proc` entry but has already released the
|
|
||||||
/// Steam instance, so waiting on it would spin the full shutdown deadline for nothing.)
|
|
||||||
fn pid_running(pid: u32) -> bool {
|
|
||||||
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
// Field 3 (state) follows the parenthesized comm — split after the LAST ')' since comm can
|
|
||||||
// itself contain parentheses.
|
|
||||||
stat.rsplit_once(')')
|
|
||||||
.and_then(|(_, rest)| rest.split_whitespace().next())
|
|
||||||
.is_some_and(|state| state != "Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the
|
||||||
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect
|
||||||
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the
|
||||||
@@ -1233,10 +1136,6 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
|||||||
let start_unit = || -> Result<()> {
|
let start_unit = || -> Result<()> {
|
||||||
let status = Command::new("systemd-run")
|
let status = Command::new("systemd-run")
|
||||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
|
||||||
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
|
|
||||||
// that would abort gamescope at startup.
|
|
||||||
.arg("--property=UnsetEnvironment=DISPLAY WAYLAND_DISPLAY")
|
|
||||||
.arg("--setenv=BACKEND=headless")
|
.arg("--setenv=BACKEND=headless")
|
||||||
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
||||||
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
||||||
@@ -1402,16 +1301,7 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
|||||||
])
|
])
|
||||||
.args(app.split_whitespace())
|
.args(app.split_whitespace())
|
||||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
|
||||||
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
|
|
||||||
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
|
|
||||||
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
|
|
||||||
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
|
|
||||||
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
|
|
||||||
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
|
|
||||||
.env_remove("DISPLAY")
|
|
||||||
.env_remove("WAYLAND_DISPLAY");
|
|
||||||
if let Ok(logf) = std::fs::File::create(log) {
|
if let Ok(logf) = std::fs::File::create(log) {
|
||||||
if let Ok(log2) = logf.try_clone() {
|
if let Ok(log2) = logf.try_clone() {
|
||||||
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
||||||
@@ -1697,10 +1587,7 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE};
|
||||||
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
|
|
||||||
MIN_GAMESCOPE,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
@@ -1736,27 +1623,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn desktop_steam_cgroup_ownership() {
|
|
||||||
// A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host).
|
|
||||||
assert!(!cgroup_is_punktfunk_owned(
|
|
||||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-steam-48605.scope"
|
|
||||||
));
|
|
||||||
// KDE spawns app scopes too; still foreign.
|
|
||||||
assert!(!cgroup_is_punktfunk_owned(
|
|
||||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-steam@0f3a.service"
|
|
||||||
));
|
|
||||||
// Our own dedicated spawn tree (Steam nested under the host service).
|
|
||||||
assert!(cgroup_is_punktfunk_owned(
|
|
||||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service"
|
|
||||||
));
|
|
||||||
// The host-managed gamescope session unit (SESSION_UNIT).
|
|
||||||
assert!(cgroup_is_punktfunk_owned(
|
|
||||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-gamescope.service"
|
|
||||||
));
|
|
||||||
assert!(!cgroup_is_punktfunk_owned(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_version_banner() {
|
fn parses_version_banner() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. |
|
||||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||||
|
|
||||||
## Audio / microphone
|
## Audio / microphone
|
||||||
@@ -138,7 +138,7 @@ notes for context.
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — the dominant lever above ~1 Gbps. On by default; auto-falls back to `sendmmsg`. Set `0` if a NIC/middlebox mishandles GSO. |
|
||||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
||||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||||
@@ -158,7 +158,7 @@ A few knobs are read by the native **clients**, not the host:
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck, D3D11VA/Vulkan on Windows) with a software fallback. |
|
| `PUNKTFUNK_DECODER` | `software` · `vaapi` (Linux) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, D3D11VA on Windows) with a software fallback. |
|
||||||
|
|
||||||
## Bitrate
|
## Bitrate
|
||||||
|
|
||||||
|
|||||||
@@ -112,13 +112,13 @@
|
|||||||
// hosts); otherwise the host falls back to X-Box 360.
|
// hosts); otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
||||||
|
|
||||||
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||||
// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||||
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
|
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
|
||||||
|
|
||||||
// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
|
// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||||
// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
|
// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||||
// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||||
|
|
||||||
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
|
|||||||
@@ -207,14 +207,11 @@ ujust add-user-to-input-group # NOT `usermod` on Bazzite (see the note abov
|
|||||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||||
```
|
```
|
||||||
|
|
||||||
The core rule contents, for reference (the full file additionally grants the `input` group access
|
The rule contents, for reference:
|
||||||
to the vhci attach files and to the hidraw nodes of the virtual pads the host creates — Steam/SDL
|
|
||||||
need hidraw to send a DualSense's adaptive-trigger/lightbar feedback and reliable rumble):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
|
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||||
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
|
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||||
KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0660", TAG+="uaccess" # + 0DF2/09CC/2009/1205/1102
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -614,15 +614,14 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
|||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the
|
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id
|
||||||
/// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE
|
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one
|
||||||
/// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are
|
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and-
|
||||||
/// fire-and-forget) — acking them is all they need.
|
/// forget) — acking them is all they need.
|
||||||
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
||||||
|
|
||||||
// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
|
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's
|
||||||
// answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host.
|
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer.
|
||||||
// Per the UMDF marshalling convention the report data is the input buffer.
|
|
||||||
fn on_set_feature(request: &Request) -> NTSTATUS {
|
fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||||
if let Ok((bytes, _)) = request.input_bytes(64) {
|
if let Ok((bytes, _)) = request.input_bytes(64) {
|
||||||
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||||
@@ -637,67 +636,32 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
|
|||||||
let n = src.len().min(64);
|
let n = src.len().min(64);
|
||||||
g[..n].copy_from_slice(&src[..n]);
|
g[..n].copy_from_slice(&src[..n]);
|
||||||
}
|
}
|
||||||
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
|
|
||||||
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
|
|
||||||
// reports instead. Publish them to the host through the same output slot + seq the
|
|
||||||
// output path uses, re-prefixed with the report-id 0 byte so the host's
|
|
||||||
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
|
|
||||||
if device_type() == 3
|
|
||||||
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
|
|
||||||
&& let Some(view) = CHANNEL.data()
|
|
||||||
{
|
|
||||||
let mut out = [0u8; 64];
|
|
||||||
let n = src.len().min(63);
|
|
||||||
out[1..1 + n].copy_from_slice(&src[..n]);
|
|
||||||
view.write_bytes(OFF_OUTPUT, &out);
|
|
||||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
|
||||||
view.write_u32(OFF_OUT_SEQ, seq);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deck identity: build the GET_FEATURE reply from the latched SET_FEATURE command — the
|
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the
|
||||||
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
||||||
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
||||||
/// truth this mirrors). Anything else echoes the latched command.
|
/// truth this mirrors). Anything else echoes the latched command.
|
||||||
fn deck_feature_reply() -> [u8; 64] {
|
fn deck_feature_reply() -> [u8; 64] {
|
||||||
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
||||||
// Per-pad unit id "PF" + the pad index the host stamped into the section (0 while the
|
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
|
||||||
// channel hasn't attached yet) — matches steam_proto::deck_unit_id / deck_serial, so two
|
let serial = b"PFDK50460003";
|
||||||
// virtual Decks never collide in Steam's eyes.
|
|
||||||
let idx = CHANNEL
|
|
||||||
.data()
|
|
||||||
.map(|v| v.read_u32(OFF_PAD_INDEX))
|
|
||||||
.unwrap_or(0)
|
|
||||||
& 0xFF;
|
|
||||||
let unit_id: u32 = 0x5046_0000 | idx;
|
|
||||||
// Steam validates the unit serial's PREFIX before accepting it: a "PF"-leading serial is
|
|
||||||
// REJECTED ("Invalid or missing unit serial number …") and Steam then substitutes a hash and
|
|
||||||
// MANGLES the displayed name ("Steam Deck Controllerggg"). An 'F'-leading serial passes, so we
|
|
||||||
// keep our PunktFunk marker one slot in ("FVPF") — still distinct enough for the Linux side's
|
|
||||||
// physical-Deck self-detection while satisfying Steam's format check. (This, not the build-time
|
|
||||||
// attributes below, is what un-mangles the name — verified by A/B on .173.)
|
|
||||||
let unit_serial = format!("FVPF{unit_id:08X}");
|
|
||||||
let unit_serial = unit_serial.as_bytes();
|
|
||||||
let mut r = [0u8; 64];
|
let mut r = [0u8; 64];
|
||||||
match last[0] {
|
match last[0] {
|
||||||
0x83 => {
|
0x83 => {
|
||||||
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
||||||
r[0] = 0x83;
|
r[0] = 0x83;
|
||||||
r[1] = 0x2D;
|
r[1] = 0x2D;
|
||||||
// Attribute semantics per SDL's controller_constants.h: 0x04 = FIRMWARE_BUILD_TIME
|
|
||||||
// and 0x0A = BOOTLOADER_BUILD_TIME are unix timestamps that must look like real build
|
|
||||||
// dates (the old unit-id-derived junk here was cosmetic; the name mangling was the
|
|
||||||
// serial prefix). Uniqueness rides the serial.
|
|
||||||
let attrs: [(u8, u32); 9] = [
|
let attrs: [(u8, u32); 9] = [
|
||||||
(0x01, 0x1205), // ATTRIB_PRODUCT_ID
|
(0x01, 0x1205),
|
||||||
(0x02, 0), // ATTRIB_CAPABILITIES
|
(0x02, 0),
|
||||||
(0x0A, 0x6408_9000), // ATTRIB_BOOTLOADER_BUILD_TIME (2023-03-08)
|
(0x0A, unit_id),
|
||||||
(0x04, 0x66A8_C000), // ATTRIB_FIRMWARE_BUILD_TIME (2024-07-30)
|
(0x04, unit_id ^ 0x5555_5555),
|
||||||
(0x09, 0x2E), // ATTRIB_BOARD_REVISION (captured)
|
(0x09, 0x2E),
|
||||||
(0x0B, 0x0FA0), // ATTRIB_CONNECTION_INTERVAL_IN_US (4 ms)
|
(0x0B, 0x0FA0),
|
||||||
(0x0D, 0),
|
(0x0D, 0),
|
||||||
(0x0C, 0),
|
(0x0C, 0),
|
||||||
(0x0E, 0),
|
(0x0E, 0),
|
||||||
@@ -710,19 +674,12 @@ fn deck_feature_reply() -> [u8; 64] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
0xAE => {
|
0xAE => {
|
||||||
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. Steam requests two strings: attr
|
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…].
|
||||||
// 0x00 = ATTRIB_STR_BOARD_SERIAL (the PCB serial) and 0x01 = ATTRIB_STR_UNIT_SERIAL.
|
let attr = if last[2] != 0 { last[2] } else { 0x01 };
|
||||||
// Echo the exact attr requested (last[2]) — the unit serial is the one that matters:
|
|
||||||
// getting its format right (FVPF…, see above) is what un-mangles the displayed name.
|
|
||||||
// Steam ALSO validates the PCB serial against a Valve-internal format we don't have a
|
|
||||||
// real capture of; it logs "Deck Controller PCB Serial# invalid" for ANY value we send
|
|
||||||
// (including an empty one — verified on .173), but that line is BENIGN: unlike a bad
|
|
||||||
// unit serial, it does not mangle the name, change the handle, or block promotion. So we
|
|
||||||
// serve the unit serial for both attrs and accept the log.
|
|
||||||
r[0] = 0xAE;
|
r[0] = 0xAE;
|
||||||
r[1] = unit_serial.len() as u8;
|
r[1] = serial.len() as u8;
|
||||||
r[2] = last[2];
|
r[2] = attr;
|
||||||
r[3..3 + unit_serial.len()].copy_from_slice(unit_serial);
|
r[3..3 + serial.len()].copy_from_slice(serial);
|
||||||
}
|
}
|
||||||
_ => r.copy_from_slice(&last),
|
_ => r.copy_from_slice(&last),
|
||||||
}
|
}
|
||||||
@@ -779,32 +736,23 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
let string_id = id_val & 0xFFFF;
|
let string_id = id_val & 0xFFFF;
|
||||||
let devtype = device_type();
|
let devtype = device_type();
|
||||||
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
||||||
let s: String = match string_id {
|
let s: &str = match string_id {
|
||||||
0 | 0x000e => match devtype {
|
0 | 0x000e => match devtype {
|
||||||
1 => "Sony Computer Entertainment".into(),
|
1 => "Sony Computer Entertainment",
|
||||||
3 => "Valve Software".into(),
|
3 => "Valve Software",
|
||||||
_ => "Sony Interactive Entertainment".into(),
|
_ => "Sony Interactive Entertainment",
|
||||||
},
|
},
|
||||||
2 | 0x0010 => match devtype {
|
2 | 0x0010 => match devtype {
|
||||||
1 => "DEADBEEF0001".into(),
|
1 => "DEADBEEF0001",
|
||||||
2 => "35533AD6E775".into(),
|
2 => "35533AD6E775",
|
||||||
// Per-pad Deck serial — must agree with deck_feature_reply's 0xAE answer (Steam
|
3 => "PFDK50460003",
|
||||||
// reads both and uses the serial to identify units).
|
_ => "35533AD6E774",
|
||||||
3 => {
|
|
||||||
let idx = CHANNEL
|
|
||||||
.data()
|
|
||||||
.map(|v| v.read_u32(OFF_PAD_INDEX))
|
|
||||||
.unwrap_or(0)
|
|
||||||
& 0xFF;
|
|
||||||
format!("FVPF{:08X}", 0x5046_0000u32 | idx)
|
|
||||||
}
|
|
||||||
_ => "35533AD6E774".into(),
|
|
||||||
},
|
},
|
||||||
_ => match devtype {
|
_ => match devtype {
|
||||||
1 => "Wireless Controller".into(),
|
1 => "Wireless Controller",
|
||||||
2 => "DualSense Edge Wireless Controller".into(),
|
2 => "DualSense Edge Wireless Controller",
|
||||||
3 => "Steam Deck Controller".into(),
|
3 => "Steam Deck Controller",
|
||||||
_ => "DualSense Wireless Controller".into(),
|
_ => "DualSense Wireless Controller",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||||
@@ -816,8 +764,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
||||||
/// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. Read fresh on each enumeration
|
/// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If
|
||||||
/// query — cheap. If
|
|
||||||
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
||||||
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
||||||
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
||||||
|
|||||||
@@ -17,24 +17,3 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
|
|||||||
# are root-only by default while the host runs as a user service — grant the `input`
|
# are root-only by default while the host runs as a user service — grant the `input`
|
||||||
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
|
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
|
||||||
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
|
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
|
||||||
|
|
||||||
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
|
|
||||||
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
|
|
||||||
# evdev API for any of it — and Steam without hidraw demotes a PlayStation pad to a generic evdev
|
|
||||||
# device, losing its rumble handling too. hidraw nodes are root-only by default; the distro's
|
|
||||||
# steam-devices rules + logind's uaccess ACL cover only the active seat session, so a game
|
|
||||||
# launched outside it (a headless/dedicated streaming session) is silently feedback-dead.
|
|
||||||
# GROUP="input" makes access follow the same group the host itself already requires.
|
|
||||||
# KERNELS matches the HID device name (works for UHID devices, which have no USB parent);
|
|
||||||
# the ATTRS pair covers the usbip/gadget Deck, which IS a (virtual) USB device.
|
|
||||||
# DualSense (054C:0CE6) / DualSense Edge (054C:0DF2) / DualShock 4 (054C:09CC)
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*054C:09CC*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
# Switch Pro Controller (057E:2009) — SDL's hidapi driver wants hidraw for rumble/LEDs too
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*057E:2009*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
# Steam Deck (28DE:1205) / classic Steam Controller (28DE:1102), UHID and usbip/gadget forms
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*28DE:1205*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
KERNEL=="hidraw*", KERNELS=="*28DE:1102*", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1205", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1102", GROUP="input", MODE="0660", TAG+="uaccess"
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Repo pre-commit hook: the same rustfmt gates as pre-push, but at commit time for faster
|
|
||||||
# feedback. Enable once per clone:
|
|
||||||
#
|
|
||||||
# git config core.hooksPath scripts/git-hooks
|
|
||||||
#
|
|
||||||
# NOTE this is the convenience layer only — plumbing commits (commit-tree) bypass pre-commit,
|
|
||||||
# which is why pre-push re-runs the identical checks and is the actual enforcement point.
|
|
||||||
exec "$(git rev-parse --show-toplevel)/scripts/git-hooks/pre-push"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Repo pre-push hook: run the exact rustfmt gates CI runs, so a push can never fail on
|
|
||||||
# formatting alone. Enable once per clone:
|
|
||||||
#
|
|
||||||
# git config core.hooksPath scripts/git-hooks
|
|
||||||
#
|
|
||||||
# Kept to the CHEAP checks on purpose (rustfmt only parses — a couple of seconds): clippy/tests
|
|
||||||
# stay in CI and the per-box verify recipes. Runs regardless of how the commits were made
|
|
||||||
# (plain `git commit`, amends, or plumbing like commit-tree — pre-push sees them all).
|
|
||||||
#
|
|
||||||
# Escape hatch for a genuine emergency: `git push --no-verify`.
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
root=$(git rev-parse --show-toplevel)
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "pre-push: $1" >&2
|
|
||||||
echo "pre-push: fix with: $2" >&2
|
|
||||||
echo "pre-push: (bypass in an emergency with: git push --no-verify)" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Main workspace — mirrors ci.yml "Format": cargo fmt --all --check.
|
|
||||||
if ! (cd "$root" && cargo fmt --all --check >/dev/null 2>&1); then
|
|
||||||
fail "main workspace is not rustfmt-clean (ci.yml would fail)" \
|
|
||||||
"cargo fmt --all"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
|
|
||||||
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
|
|
||||||
if ! (cd "$root/packaging/windows/drivers" \
|
|
||||||
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then
|
|
||||||
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
|
|
||||||
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
throughput-sweep.py — punktfunk data-plane throughput diagnostic.
|
|
||||||
|
|
||||||
Drives the built-in `punktfunk-probe --speed-test` up a ladder of target
|
|
||||||
bitrates and tabulates, per point:
|
|
||||||
|
|
||||||
target | delivered | efficiency | link_loss | host_drop | send_dropped
|
|
||||||
|
|
||||||
The probe bursts synthetic FEC filler over the REAL UDP + GF(2^16) FEC + AES-GCM
|
|
||||||
data plane with video PAUSED (host `run_probe_burst`), so this isolates the
|
|
||||||
transport from NVENC entirely. It separates the two failure modes the raw
|
|
||||||
`iperf` number can't:
|
|
||||||
|
|
||||||
* host_drop = wire packets the host could not even hand to the kernel
|
|
||||||
(send buffer full / send thread can't keep up) -> HOST side
|
|
||||||
* link_loss = wire packets sent but never arrived
|
|
||||||
(link saturation, or client recv buffer/CPU drop) -> LINK/CLIENT
|
|
||||||
|
|
||||||
How to read the result:
|
|
||||||
|
|
||||||
* delivered ~= target, ~0 loss all the way to 2-3 Gbps
|
|
||||||
-> the transport is NOT your wall. The ~500 Mbps ceiling is the ENCODER
|
|
||||||
(CBR undershoot with no filler / codec level cap). Chase that next.
|
|
||||||
* delivered climbs then flattens while host_drop rises
|
|
||||||
-> host send buffer / single send thread. (SO_SNDBUF, USO chunk size.)
|
|
||||||
* delivered flattens while link_loss rises, host_drop ~0
|
|
||||||
-> the link itself, or the client recv path (8 MB RCVBUF clamp / recv CPU).
|
|
||||||
Cross-check with `iperf3 -u -b 2G` between the two boxes to decide
|
|
||||||
which: if iperf also caps, it's the link/OS, not punktfunk.
|
|
||||||
|
|
||||||
Prereq (one-time) — pair the probe with your host. Arm pairing on the host,
|
|
||||||
then:
|
|
||||||
|
|
||||||
punktfunk-probe --connect HOST:9777 --pair <PIN>
|
|
||||||
|
|
||||||
(the probe stores its identity in ~/.config/punktfunk/). After that the sweep
|
|
||||||
reuses the trusted identity; pass --pin <HOSTFP> to also pin the host.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
scripts/throughput-sweep.py HOST[:PORT] [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--pin HEX64 pin the host cert fingerprint (host logs it at startup)
|
|
||||||
--mode WxHxFPS request this virtual-display mode (default: probe default)
|
|
||||||
--ladder A,B,C target bitrates in Mbps
|
|
||||||
(default: 250,500,750,1000,1250,1500,2000,3000)
|
|
||||||
--dur-ms N burst duration per point in ms (default: 2000)
|
|
||||||
--bin PATH path to punktfunk-probe (default: target/release/punktfunk-probe)
|
|
||||||
--build (re)build the probe before sweeping
|
|
||||||
--warmup-video-kbps N encoder bitrate during warmup (default: 20000 = 20 Mbps)
|
|
||||||
--dry-run print the probe commands without running them
|
|
||||||
--self-test run the output parser against a synthetic line and exit
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
scripts/throughput-sweep.py 192.168.1.173
|
|
||||||
scripts/throughput-sweep.py 192.168.1.173:9777 --pin a1b2... --ladder 500,1000,2000
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
DEFAULT_BIN = REPO / "target" / "release" / "punktfunk-probe"
|
|
||||||
DEFAULT_LADDER = "250,500,750,1000,1250,1500,2000,3000"
|
|
||||||
DEFAULT_PORT = 9777
|
|
||||||
SPEED_LINE = "SPEED TEST complete"
|
|
||||||
|
|
||||||
|
|
||||||
# ---- output parsing -------------------------------------------------------
|
|
||||||
|
|
||||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(text: str) -> str:
|
|
||||||
"""tracing emits ANSI color even into a pipe; strip it or `key=value` never matches."""
|
|
||||||
return ANSI_RE.sub("", text)
|
|
||||||
|
|
||||||
|
|
||||||
def _field(line: str, key: str) -> str | None:
|
|
||||||
"""Extract `key=value` or `key="value"` from a tracing log line (order-agnostic)."""
|
|
||||||
m = re.search(rf'\b{re.escape(key)}=(?:"([^"]*)"|(\S+))', line)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
return m.group(1) if m.group(1) is not None else m.group(2)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_speed_line(text: str) -> dict | None:
|
|
||||||
"""Find the 'SPEED TEST complete' line in probe output and pull its fields.
|
|
||||||
|
|
||||||
Field names mirror clients/probe/src/main.rs:698-708 exactly."""
|
|
||||||
text = strip_ansi(text)
|
|
||||||
line = next((ln for ln in text.splitlines() if SPEED_LINE in ln), None)
|
|
||||||
if line is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def num(key: str) -> float | None:
|
|
||||||
v = _field(line, key)
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(v.rstrip("%"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"target_mbps": num("target_mbps"),
|
|
||||||
"delivered_mbps": num("delivered_mbps"),
|
|
||||||
"link_loss_pct": num("link_loss_pct"),
|
|
||||||
"host_drop_pct": num("host_drop_pct"),
|
|
||||||
"wire_pkts_sent": num("wire_pkts_sent"),
|
|
||||||
"wire_pkts_recv": num("wire_pkts_recv"),
|
|
||||||
"send_dropped": num("send_dropped"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def scan_warnings(text: str) -> list[str]:
|
|
||||||
"""Surface client-side red flags that change interpretation of the numbers."""
|
|
||||||
text = strip_ansi(text)
|
|
||||||
flags = []
|
|
||||||
if "SPEED TEST declined" in text:
|
|
||||||
flags.append("host declined the speed test (old host build?) — check the host log")
|
|
||||||
if "UDP socket buffer capped" in text:
|
|
||||||
flags.append("client SO_RCVBUF capped below target (raise kern.ipc.maxsockbuf)")
|
|
||||||
if "falling back to per-packet sends" in text or "USO unsupported" in text:
|
|
||||||
flags.append("USO/GSO unsupported on this path -> scalar per-packet sends")
|
|
||||||
if "recvmsg_x" in text and "disabl" in text.lower():
|
|
||||||
flags.append("recvmsg_x batching latched off -> one syscall per packet")
|
|
||||||
return flags
|
|
||||||
|
|
||||||
|
|
||||||
# ---- probe invocation -----------------------------------------------------
|
|
||||||
|
|
||||||
def build_probe() -> None:
|
|
||||||
env = dict(os.environ)
|
|
||||||
# audiopus_sys / opus vendored CMake needs this on recent CMake (see project memory).
|
|
||||||
env.setdefault("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
|
|
||||||
print("building punktfunk-probe (release)...", flush=True)
|
|
||||||
subprocess.run(
|
|
||||||
["cargo", "build", "--release", "-p", "punktfunk-probe"],
|
|
||||||
cwd=REPO, env=env, check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def seconds_for(dur_ms: int) -> int:
|
|
||||||
"""Receive-loop cap: 2s probe warmup + burst + slack for connect/settle/report."""
|
|
||||||
return math.ceil(2 + dur_ms / 1000 + 3)
|
|
||||||
|
|
||||||
|
|
||||||
def probe_cmd(bin_path: Path, host: str, target_mbps: int, dur_ms: int,
|
|
||||||
seconds: int, args: argparse.Namespace) -> list[str]:
|
|
||||||
cmd = [
|
|
||||||
str(bin_path),
|
|
||||||
"--connect", host,
|
|
||||||
"--bitrate", str(args.warmup_video_kbps),
|
|
||||||
"--speed-test", f"{target_mbps * 1000}:{dur_ms}",
|
|
||||||
"--seconds", str(seconds),
|
|
||||||
"--quit", # tear the host session down cleanly between points
|
|
||||||
]
|
|
||||||
if args.pin:
|
|
||||||
cmd += ["--pin", args.pin]
|
|
||||||
if args.mode:
|
|
||||||
cmd += ["--mode", args.mode]
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
def run_point(bin_path: Path, host: str, target_mbps: int,
|
|
||||||
args: argparse.Namespace) -> tuple[dict | None, list[str], str]:
|
|
||||||
seconds = seconds_for(args.dur_ms)
|
|
||||||
cmd = probe_cmd(bin_path, host, target_mbps, args.dur_ms, seconds, args)
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.setdefault("RUST_LOG", "info")
|
|
||||||
try:
|
|
||||||
proc = subprocess.run(
|
|
||||||
cmd, cwd=REPO, env=env, capture_output=True, text=True,
|
|
||||||
timeout=seconds + 25,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired as e:
|
|
||||||
out = (e.stdout or "") + (e.stderr or "")
|
|
||||||
return None, ["probe timed out (no SPEED TEST line)"], out
|
|
||||||
out = (proc.stdout or "") + (proc.stderr or "")
|
|
||||||
return parse_speed_line(out), scan_warnings(out), out
|
|
||||||
|
|
||||||
|
|
||||||
# ---- reporting ------------------------------------------------------------
|
|
||||||
|
|
||||||
def verdict(rows: list[dict]) -> str:
|
|
||||||
done = [r for r in rows if r.get("delivered_mbps") is not None]
|
|
||||||
if not done:
|
|
||||||
return "No successful measurements — check pairing/--pin and host reachability."
|
|
||||||
peak = max(r["delivered_mbps"] for r in done)
|
|
||||||
top = max(done, key=lambda r: r["target_mbps"] or 0)
|
|
||||||
hd = top.get("host_drop_pct") or 0.0
|
|
||||||
ll = top.get("link_loss_pct") or 0.0
|
|
||||||
lines = [f"Peak delivered: ~{peak:.0f} Mbps."]
|
|
||||||
if peak >= (top["target_mbps"] or 0) * 0.9 and hd < 1 and ll < 1:
|
|
||||||
lines.append(
|
|
||||||
"Transport tracked the target with ~0 loss to the top of the ladder: "
|
|
||||||
"the transport is NOT the wall. Your ~500 Mbps ceiling is the ENCODER "
|
|
||||||
"(CBR undershoot / no filler / codec level cap) — investigate that next.")
|
|
||||||
elif hd >= ll and hd >= 1:
|
|
||||||
lines.append(
|
|
||||||
f"host_drop dominates at the top ({hd:.1f}% vs link_loss {ll:.1f}%): "
|
|
||||||
"the HOST send path is the wall (SO_SNDBUF / single send thread / USO "
|
|
||||||
"chunk=16). Fix host-side.")
|
|
||||||
elif ll >= 1:
|
|
||||||
lines.append(
|
|
||||||
f"link_loss dominates at the top ({ll:.1f}% vs host_drop {hd:.1f}%): "
|
|
||||||
"the LINK or CLIENT recv path caps here. Cross-check with "
|
|
||||||
"`iperf3 -u -b <peak+50%>M` between the boxes — if iperf also caps, "
|
|
||||||
"it's the link/OS, not punktfunk; if iperf is clean, it's the client "
|
|
||||||
"recv buffer (8 MB) / recv CPU.")
|
|
||||||
else:
|
|
||||||
lines.append("Loss stayed low but delivered plateaued below target — "
|
|
||||||
"likely the encoder warmup rate or a soft CPU ceiling; "
|
|
||||||
"re-run with a longer --dur-ms to confirm.")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def print_table(rows: list[dict]) -> None:
|
|
||||||
hdr = ["target", "delivered", "eff", "link_loss", "host_drop", "send_drop"]
|
|
||||||
widths = [8, 10, 6, 10, 10, 10]
|
|
||||||
|
|
||||||
def fmt_row(cells):
|
|
||||||
return " ".join(str(c).rjust(w) for c, w in zip(cells, widths))
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(fmt_row(hdr))
|
|
||||||
print(fmt_row(["-" * w for w in widths]))
|
|
||||||
for r in rows:
|
|
||||||
if r.get("delivered_mbps") is None:
|
|
||||||
print(fmt_row([f"{r['target_mbps']:.0f}", "FAIL", "-", "-", "-", "-"]))
|
|
||||||
continue
|
|
||||||
t = r["target_mbps"] or 0
|
|
||||||
d = r["delivered_mbps"]
|
|
||||||
eff = f"{(d / t * 100):.0f}%" if t else "-"
|
|
||||||
print(fmt_row([
|
|
||||||
f"{t:.0f}", f"{d:.0f}", eff,
|
|
||||||
f"{r.get('link_loss_pct', 0):.1f}%",
|
|
||||||
f"{r.get('host_drop_pct', 0):.1f}%",
|
|
||||||
f"{int(r.get('send_dropped') or 0)}",
|
|
||||||
]))
|
|
||||||
print("\n(all rates in Mbps; probe bursts filler with video paused)\n")
|
|
||||||
|
|
||||||
|
|
||||||
# ---- main -----------------------------------------------------------------
|
|
||||||
|
|
||||||
SELF_TEST_LINE = (
|
|
||||||
'2026-07-13T12:00:00.000Z INFO punktfunk_probe: SPEED TEST complete '
|
|
||||||
'target_kbps=2000000 target_mbps=2000 delivered_mbps=512 '
|
|
||||||
'link_loss_pct="3.4%" host_drop_pct="0.0%" wire_pkts_sent=170000 '
|
|
||||||
'wire_pkts_recv=164220 send_dropped=0'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def self_test() -> int:
|
|
||||||
got = parse_speed_line(SELF_TEST_LINE)
|
|
||||||
want = {
|
|
||||||
"target_mbps": 2000.0, "delivered_mbps": 512.0,
|
|
||||||
"link_loss_pct": 3.4, "host_drop_pct": 0.0,
|
|
||||||
"wire_pkts_sent": 170000.0, "wire_pkts_recv": 164220.0,
|
|
||||||
"send_dropped": 0.0,
|
|
||||||
}
|
|
||||||
ok = got == want
|
|
||||||
print("parser self-test:", "PASS" if ok else "FAIL")
|
|
||||||
if not ok:
|
|
||||||
print(" got: ", got)
|
|
||||||
print(" want:", want)
|
|
||||||
return 0 if ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
p = argparse.ArgumentParser(add_help=True, description="punktfunk throughput sweep")
|
|
||||||
p.add_argument("host", nargs="?", help="HOST[:PORT] of the punktfunk host")
|
|
||||||
p.add_argument("--pin")
|
|
||||||
p.add_argument("--mode")
|
|
||||||
p.add_argument("--ladder", default=DEFAULT_LADDER)
|
|
||||||
p.add_argument("--dur-ms", type=int, default=2000)
|
|
||||||
p.add_argument("--bin", type=Path, default=DEFAULT_BIN)
|
|
||||||
p.add_argument("--build", action="store_true")
|
|
||||||
p.add_argument("--warmup-video-kbps", type=int, default=20000)
|
|
||||||
p.add_argument("--dry-run", action="store_true")
|
|
||||||
p.add_argument("--self-test", action="store_true")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
if args.self_test:
|
|
||||||
return self_test()
|
|
||||||
|
|
||||||
if not args.host:
|
|
||||||
p.error("HOST is required (e.g. 192.168.1.173 or 192.168.1.173:9777)")
|
|
||||||
|
|
||||||
host = args.host if ":" in args.host else f"{args.host}:{DEFAULT_PORT}"
|
|
||||||
try:
|
|
||||||
ladder = [int(x) for x in args.ladder.split(",") if x.strip()]
|
|
||||||
except ValueError:
|
|
||||||
p.error(f"bad --ladder {args.ladder!r} (want comma-separated Mbps ints)")
|
|
||||||
|
|
||||||
bin_path = args.bin
|
|
||||||
if args.build or not bin_path.exists():
|
|
||||||
if shutil.which("cargo") is None:
|
|
||||||
p.error("cargo not found and probe binary missing; build it or pass --bin")
|
|
||||||
build_probe()
|
|
||||||
if not bin_path.exists():
|
|
||||||
p.error(f"probe binary not found at {bin_path}")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("# dry run — commands that WOULD run:\n")
|
|
||||||
for t in ladder:
|
|
||||||
secs = seconds_for(args.dur_ms)
|
|
||||||
print(" " + " ".join(probe_cmd(bin_path, host, t, args.dur_ms, secs, args)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
print(f"sweeping {host} ladder={ladder} Mbps dur={args.dur_ms}ms\n")
|
|
||||||
rows: list[dict] = []
|
|
||||||
all_flags: set[str] = set()
|
|
||||||
for t in ladder:
|
|
||||||
print(f" -> {t} Mbps ...", end="", flush=True)
|
|
||||||
parsed, flags, raw = run_point(bin_path, host, t, args)
|
|
||||||
all_flags.update(flags)
|
|
||||||
if parsed is not None and parsed.get("delivered_mbps") is None:
|
|
||||||
parsed = None # SPEED TEST line present but unparseable — treat as a failed point
|
|
||||||
if parsed is None:
|
|
||||||
print(" FAIL")
|
|
||||||
# surface why, briefly
|
|
||||||
tail = "\n".join(raw.strip().splitlines()[-3:])
|
|
||||||
if tail:
|
|
||||||
print(" " + tail.replace("\n", "\n "))
|
|
||||||
rows.append({"target_mbps": float(t), "delivered_mbps": None})
|
|
||||||
else:
|
|
||||||
parsed["target_mbps"] = parsed.get("target_mbps") or float(t)
|
|
||||||
print(f" delivered {parsed['delivered_mbps']:.0f} Mbps "
|
|
||||||
f"link_loss {parsed.get('link_loss_pct', 0):.1f}% "
|
|
||||||
f"host_drop {parsed.get('host_drop_pct', 0):.1f}%")
|
|
||||||
rows.append(parsed)
|
|
||||||
|
|
||||||
print_table(rows)
|
|
||||||
if all_flags:
|
|
||||||
print("Flags seen in probe logs:")
|
|
||||||
for f in sorted(all_flags):
|
|
||||||
print(f" ! {f}")
|
|
||||||
print()
|
|
||||||
print(verdict(rows))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||