Compare commits

2 Commits

Author SHA1 Message Date
enricobuehler 6241639042 debug(touch): mirror PUNKTFUNK_TOUCH_DEBUG lines to a data-dir file
The presenter runs in the spawned punktfunk-session binary, whose stderr
Steam's game-mode reaper swallows on the Deck — so also append the finger/
mouse debug lines to $XDG_DATA_HOME/punktfunk-touch-debug.log (host-visible
under ~/.var/app/io.unom.Punktfunk), which survives regardless of how the
client is launched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:59:46 +02:00
enricobuehler 7c72899a49 debug(touch): env-gated PUNKTFUNK_TOUCH_DEBUG finger/mouse logging
Logs every raw SDL Finger{Down,Motion,Up} (with the is_direct_touch
result) and MouseMotion/MouseButton event when PUNKTFUNK_TOUCH_DEBUG=1,
to diagnose why touchscreen input is dropped on the Steam Deck under
game-mode gamescope (both trackpad and touch-passthrough dead at once =
finger events not reaching the engine). Zero behavior change when unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:07:40 +02:00
94 changed files with 1081 additions and 6089 deletions
-18
View File
@@ -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"]
-10
View File
@@ -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
Generated
+14 -14
View File
@@ -2145,7 +2145,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.11.0" version = "0.10.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2799,7 +2799,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2992,7 +2992,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3008,7 +3008,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3024,7 +3024,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3039,7 +3039,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3058,7 +3058,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3089,7 +3089,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3161,7 +3161,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3175,7 +3175,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.11.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -35,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.11.0" version = "0.10.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -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 323 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 32 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+8 -15
View File
@@ -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"
}
}
+4 -139
View File
@@ -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
+297
View File
@@ -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()
+1 -5
View File
@@ -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/"
-8
View File
@@ -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",
-5
View File
@@ -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".
+62 -122
View File
@@ -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);
} }
+4 -6
View File
@@ -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",
], ],
); );
+5 -123
View File
@@ -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,26 +639,9 @@ 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
// frame until something composes, and the host's pipeline build waits for a first
// frame — so an idle virtual display would time the whole speed test out. A ±2 px
// cursor wiggle over the wire is injected host-side into the right session/desktop.
for i in 0..20u32 {
let mv = InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: if i % 2 == 0 { 2 } else { -2 },
y: 0,
flags: 0,
};
let _ = conn2.send_datagram(mv.encode().to_vec().into());
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
// Baseline the packet-level counters right before the burst (video is paused during it, // 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). // 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);
@@ -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() {
+8 -48
View File
@@ -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,
+9 -130
View File
@@ -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,27 +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?
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
/// additionally shows chroma fringing; the session binary opts RADV into
/// `video_decode` precisely to get the Vulkan path. Vulkan-first is safe here
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
/// so a broken Mesa Vulkan path still lands on the working driver.
///
/// Intel (ANV) and unknown vendors keep the battle-tested zero-copy VAAPI first —
/// ANV's Vulkan Video is the least-proven Mesa path and VAAPI is what every other
/// Linux client uses there.
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
const VENDOR_NVIDIA: u32 = 0x10DE;
const VENDOR_AMD: u32 = 0x1002;
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
}
}
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`). /// `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)
@@ -1581,51 +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 on NVIDIA (no usable VAAPI) and ALL AMD
/// (Vulkan decode outperforms VAAPI on RADV — on-glass verdict; VanGogh additionally
/// chroma-fringes over VAAPI); Intel/unknown keep VAAPI first (ANV's Vulkan Video is
/// the least-proven Mesa path). A Vulkan failure streak still demotes to VAAPI, so
/// Vulkan-first can never strand a box on software decode.
#[test]
fn vulkan_over_vaapi_on_nvidia_and_amd() {
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
assert!(
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
);
assert!(
decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_over_vaapi()
);
assert!(
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
.prefer_vulkan_over_vaapi()
);
}
fn desc(matrix: u8, full_range: bool) -> ColorDesc { fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc { ColorDesc {
primaries: 1, primaries: 1,
+3 -3
View File
@@ -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,
} }
-4
View File
@@ -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 {
-1
View File
@@ -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)
} }
+15 -130
View File
@@ -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()
.into_iter()
.map(|role| match role {
Role::RequestAccess => {
let mut r = RowSpec::action("Request access — approve on the host", !self.busy);
r.header = Some("No PIN needed");
r
}
Role::Pin => {
let mut pin = RowSpec::field("PIN", pin_display.clone(), "From the host");
pin.caret = self.editing == Some(Field::Pin); pin.caret = self.editing == Some(Field::Pin);
// When a request-access path is offered above, head the PIN group so
// the two ways to pair read as alternatives.
if has_request {
pin.header = Some("Or pair with a PIN");
}
pin
}
Role::Device => {
let mut device = RowSpec::field( let mut device = RowSpec::field(
"Device name", "Device name",
self.device.clone(), self.device.clone(),
"How the host lists this device", "How the host lists this device",
); );
device.caret = self.editing == Some(Field::Device); device.caret = self.editing == Some(Field::Device);
device vec![
} pin,
Role::Pair => { device,
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_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");
+2 -47
View File
@@ -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();
-20
View File
@@ -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,
+4 -5
View File
@@ -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).
-5
View File
@@ -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.
+95
View File
@@ -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,
+1 -3
View File
@@ -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
-5
View File
@@ -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,
+3 -3
View File
@@ -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.
+44 -157
View File
@@ -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,28 +148,17 @@ 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).
// Congestion avoidance: +~6 % after a sustained clean run.
if self.probing && self.clean_windows >= 1 {
let next = self.current_kbps.saturating_mul(2).min(self.ceiling_kbps);
self.clean_windows = 0;
return self.request(next, now);
}
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE {
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps); let next = (self.current_kbps + self.current_kbps / 16 + 1).min(self.ceiling_kbps);
self.clean_windows = 0; self.clean_windows = 0;
return self.request(next, now); 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 (26 %) 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));
}
} }
+1 -129
View File
@@ -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 67 s). Pre-decode AUs are // stream stays behind for good (observed live: stuck 67 s). Pre-decode AUs are
+3 -4
View File
@@ -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,
+6 -107
View File
@@ -2,23 +2,10 @@
//! 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;
use reed_solomon_simd::ReedSolomonEncoder;
use std::sync::Mutex;
#[derive(Default)] pub struct Gf16Coder;
pub struct Gf16Coder {
/// Cached Leopard encoder (plan Phase 1.4): `reset()` re-shapes it per block while
/// reusing its working space, so steady-state frames cost no encoder construction (the
/// old `reed_solomon_simd::encode` convenience call built one — engine CPU-feature
/// detection, FFT planning, work-buffer allocs — per block). `Mutex` only to keep the
/// `&self` trait surface; a session's coder is driven by its one send thread, so the
/// lock is uncontended.
enc: Mutex<Option<ReedSolomonEncoder>>,
}
impl ErasureCoder for Gf16Coder { impl ErasureCoder for Gf16Coder {
fn scheme(&self) -> FecScheme { fn scheme(&self) -> FecScheme {
@@ -26,62 +13,16 @@ impl ErasureCoder for Gf16Coder {
} }
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
let mut out = Vec::new();
self.encode_into(data, recovery_count, &mut out)?;
Ok(out)
}
fn encode_into(
&self,
data: &[&[u8]],
recovery_count: usize,
out: &mut Vec<Vec<u8>>,
) -> Result<(), FecError> {
if recovery_count == 0 { if recovery_count == 0 {
out.clear(); return Ok(Vec::new());
return Ok(());
} }
validate_encode_shape(data)?; validate_encode_shape(data)?;
let k = data.len(); let k = data.len();
let shard_len = data[0].len(); if data[0].len() % 2 != 0 {
if shard_len % 2 != 0 {
return Err(FecError::Config("GF(2^16) shard length must be even")); return Err(FecError::Config("GF(2^16) shard length must be even"));
} }
let mut guard = self.enc.lock().unwrap_or_else(|p| p.into_inner()); reed_solomon_simd::encode(k, recovery_count, data)
let enc = match guard.as_mut() { .map_err(|_| FecError::Backend("gf16 encode"))
Some(enc) => {
enc.reset(k, recovery_count, shard_len)
.map_err(|_| FecError::Backend("gf16 encoder reset"))?;
enc
}
None => guard.insert(
ReedSolomonEncoder::new(k, recovery_count, shard_len)
.map_err(|_| FecError::Backend("gf16 encoder init"))?,
),
};
for shard in data {
enc.add_original_shard(shard)
.map_err(|_| FecError::Backend("gf16 add shard"))?;
}
let result = enc.encode().map_err(|_| FecError::Backend("gf16 encode"))?;
// Copy the parity into the caller's pooled buffers: existing `Vec`s are reused
// (clear keeps capacity), the pool grows once to the session's high-water M.
out.truncate(recovery_count);
let mut parity = result.recovery_iter();
for buf in out.iter_mut() {
let shard = parity
.next()
.ok_or(FecError::Backend("gf16 parity count"))?;
buf.clear();
buf.extend_from_slice(shard);
}
for shard in parity {
out.push(shard.to_vec());
}
if out.len() != recovery_count {
return Err(FecError::Backend("gf16 parity count"));
}
Ok(())
} }
fn reconstruct( fn reconstruct(
@@ -140,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(())
}
} }
+8 -82
View File
@@ -4,21 +4,11 @@
//! 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;
use std::sync::Mutex;
#[derive(Default)] pub struct Gf8Coder;
pub struct Gf8Coder {
/// Last-used Cauchy codec, keyed by its `(k, m)` shape (plan Phase 1.4): video blocks
/// keep one shape for long stretches (it only moves with frame size / adaptive-FEC
/// steps), so caching the matrix kills the per-block generator construction. `Mutex`
/// only to keep the `&self` trait surface; uncontended on the one send thread.
rs: Mutex<Option<(usize, usize, ReedSolomon)>>,
}
impl ErasureCoder for Gf8Coder { impl ErasureCoder for Gf8Coder {
fn scheme(&self) -> FecScheme { fn scheme(&self) -> FecScheme {
@@ -26,46 +16,20 @@ impl ErasureCoder for Gf8Coder {
} }
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
let mut out = Vec::new();
self.encode_into(data, recovery_count, &mut out)?;
Ok(out)
}
fn encode_into(
&self,
data: &[&[u8]],
recovery_count: usize,
out: &mut Vec<Vec<u8>>,
) -> Result<(), FecError> {
if recovery_count == 0 { if recovery_count == 0 {
out.clear(); return Ok(Vec::new());
return Ok(());
} }
validate_encode_shape(data)?; validate_encode_shape(data)?;
let k = data.len(); let k = data.len();
let shard_len = data[0].len(); let shard_len = data[0].len();
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
let cached = matches!(&*guard, Some((ck, cm, _)) if *ck == k && *cm == recovery_count);
if !cached {
let rs = ReedSolomon::new(k, recovery_count) let rs = ReedSolomon::new(k, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
*guard = Some((k, recovery_count, rs));
}
let rs = &guard.as_ref().expect("cache populated above").2;
// Shape the caller's pooled parity buffers without zero-filling: `encode_sep`'s
// first-input pass overwrites every parity row, so stale bytes never survive.
out.truncate(recovery_count);
for buf in out.iter_mut() {
buf.resize(shard_len, 0);
}
while out.len() < recovery_count {
out.push(vec![0u8; shard_len]);
}
// `encode_sep` reads the data shards by reference and fills the parity in place — // `encode_sep` reads the data shards by reference and fills the parity in place —
// same Cauchy codec as `encode`, without copying the data into a shards scratch. // same Cauchy codec as `encode`, without copying the data into a shards scratch.
rs.encode_sep(data, out) let mut parity: Vec<Vec<u8>> = (0..recovery_count).map(|_| vec![0u8; shard_len]).collect();
rs.encode_sep(data, &mut parity)
.map_err(|_| FecError::Backend("gf8 encode"))?; .map_err(|_| FecError::Backend("gf8 encode"))?;
Ok(()) Ok(parity)
} }
fn reconstruct( fn reconstruct(
@@ -92,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(
@@ -155,7 +81,7 @@ mod tests {
/// these vectors would break and our parity would no longer be Moonlight-decodable. /// these vectors would break and our parity would no longer be Moonlight-decodable.
#[test] #[test]
fn nanors_exact_parity_vectors() { fn nanors_exact_parity_vectors() {
let coder = Gf8Coder::default(); let coder = Gf8Coder;
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0]. // The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]]; let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]];
let parity = coder.encode(&data, 2).unwrap(); let parity = coder.encode(&data, 2).unwrap();
@@ -177,7 +103,7 @@ mod tests {
/// Round-trip: erase `m` data shards and confirm reconstruction recovers the originals. /// Round-trip: erase `m` data shards and confirm reconstruction recovers the originals.
#[test] #[test]
fn recovers_erased_data_shards() { fn recovers_erased_data_shards() {
let coder = Gf8Coder::default(); let coder = Gf8Coder;
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect(); let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let parity = coder.encode(&refs, 3).unwrap(); let parity = coder.encode(&refs, 3).unwrap();
+11 -171
View File
@@ -34,23 +34,6 @@ pub trait ErasureCoder: Send + Sync {
/// buffer instead of copying every data byte into per-shard `Vec`s first. /// buffer instead of copying every data byte into per-shard `Vec`s first.
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>; fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
/// [`encode`](Self::encode) into caller-pooled parity buffers: on success `out` holds
/// exactly `recovery_count` shards, reusing its existing `Vec` allocations (extras are
/// truncated away, missing ones are grown once to the high-water mark). The per-frame
/// hot path (plan Phase 1.4) — backends also reuse their internal codec state here, so
/// steady-state frames cost no encoder construction and no parity allocations. The
/// default delegates to `encode` (correct, unpooled) for backends without an override.
/// On error `out`'s contents are unspecified and must not be sent.
fn encode_into(
&self,
data: &[&[u8]],
recovery_count: usize,
out: &mut Vec<Vec<u8>>,
) -> Result<(), FecError> {
*out = self.encode(data, recovery_count)?;
Ok(())
}
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are /// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost. /// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
/// Returns the K original shards in order. /// Returns the K original shards in order.
@@ -60,32 +43,13 @@ 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.
pub fn coder_for(scheme: FecScheme) -> Box<dyn ErasureCoder> { pub fn coder_for(scheme: FecScheme) -> Box<dyn ErasureCoder> {
match scheme { match scheme {
FecScheme::Gf8 => Box::new(Gf8Coder::default()), FecScheme::Gf8 => Box::new(Gf8Coder),
FecScheme::Gf16 => Box::new(Gf16Coder::default()), FecScheme::Gf16 => Box::new(Gf16Coder),
} }
} }
@@ -116,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
@@ -190,109 +117,22 @@ 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::default(), 16, 4, 256, &[1, 9], &[3]);
roundtrip_into(&Gf16Coder::default(), 4, 2, 16, &[0, 3], &[]);
roundtrip_into(&Gf16Coder::default(), 4, 2, 16, &[], &[0, 1]); // nothing missing, no parity needed
}
#[test]
fn gf8_reconstruct_into_fills_only_the_holes() {
roundtrip_into(&Gf8Coder::default(), 16, 4, 256, &[0, 7], &[1]);
roundtrip_into(&Gf8Coder::default(), 4, 2, 16, &[2], &[1]);
}
#[test]
fn reconstruct_into_rejects_bad_shapes() {
let mut buf = [0u8; 4 * 8];
// Too few shards: 2 of 4 data present, no recovery.
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
let have = [true, true, false, false];
assert!(Gf16Coder::default()
.reconstruct_into(2, &mut slots, &have, &[])
.is_err());
// Recovery index out of the declared range.
let parity = [0u8; 8];
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
assert!(Gf16Coder::default()
.reconstruct_into(2, &mut slots, &have, &[(2, &parity), (3, &parity)])
.is_err());
// Mismatched recovery shard length.
let short = [0u8; 6];
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
assert!(Gf8Coder::default()
.reconstruct_into(2, &mut slots, &have, &[(0, &short), (1, &parity)])
.is_err());
// `have` length disagreeing with `data`.
let mut slots: Vec<&mut [u8]> = buf.chunks_mut(8).collect();
assert!(Gf8Coder::default()
.reconstruct_into(2, &mut slots, &[true; 3], &[(0, &parity)])
.is_err());
}
#[test] #[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).
roundtrip(&Gf8Coder::default(), 16, 4, 256, &[0, 7, 16, 19]); roundtrip(&Gf8Coder, 16, 4, 256, &[0, 7, 16, 19]);
} }
#[test] #[test]
fn gf16_recovers_within_budget() { fn gf16_recovers_within_budget() {
roundtrip(&Gf16Coder::default(), 16, 4, 256, &[1, 9, 17, 18]); roundtrip(&Gf16Coder, 16, 4, 256, &[1, 9, 17, 18]);
} }
#[test] #[test]
fn gf8_too_much_loss_errors() { fn gf8_too_much_loss_errors() {
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect(); let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = Gf8Coder::default().encode(&refs, 2).unwrap(); let recovery = Gf8Coder.encode(&refs, 2).unwrap();
let mut received: Vec<Option<Vec<u8>>> = data let mut received: Vec<Option<Vec<u8>>> = data
.iter() .iter()
.cloned() .cloned()
@@ -303,8 +143,8 @@ mod tests {
received[0] = None; received[0] = None;
received[1] = None; received[1] = None;
received[2] = None; received[2] = None;
assert!(Gf16Coder::default().scheme() == FecScheme::Gf16); assert!(Gf16Coder.scheme() == FecScheme::Gf16);
let err = Gf8Coder::default().reconstruct(8, 2, &mut received); let err = Gf8Coder.reconstruct(8, 2, &mut received);
assert!(err.is_err()); assert!(err.is_err());
} }
@@ -313,9 +153,9 @@ mod tests {
// data=2, recovery=2 expects a 4-element slice; a 3-element one must error, not // data=2, recovery=2 expects a 4-element slice; a 3-element one must error, not
// panic on the recovery-slice index (both backends). // panic on the recovery-slice index (both backends).
let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])]; let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])];
assert!(Gf16Coder::default().reconstruct(2, 2, &mut recv).is_err()); assert!(Gf16Coder.reconstruct(2, 2, &mut recv).is_err());
let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])]; let mut recv: Vec<Option<Vec<u8>>> = vec![Some(vec![0u8; 8]), None, Some(vec![0u8; 8])];
assert!(Gf8Coder::default().reconstruct(2, 2, &mut recv).is_err()); assert!(Gf8Coder.reconstruct(2, 2, &mut recv).is_err());
} }
#[test] #[test]
@@ -323,9 +163,9 @@ mod tests {
// The GF16 fast path used to clone shards verbatim without a length check. // The GF16 fast path used to clone shards verbatim without a length check.
let mut recv: Vec<Option<Vec<u8>>> = let mut recv: Vec<Option<Vec<u8>>> =
vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None]; vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None];
assert!(Gf16Coder::default().reconstruct(2, 2, &mut recv).is_err()); assert!(Gf16Coder.reconstruct(2, 2, &mut recv).is_err());
let mut recv: Vec<Option<Vec<u8>>> = let mut recv: Vec<Option<Vec<u8>>> =
vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None]; vec![Some(vec![0u8; 8]), Some(vec![0u8; 6]), None, None];
assert!(Gf8Coder::default().reconstruct(2, 2, &mut recv).is_err()); assert!(Gf8Coder.reconstruct(2, 2, &mut recv).is_err());
} }
} }
+97 -486
View File
@@ -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`]).
@@ -147,10 +147,6 @@ pub struct Packetizer {
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer — /// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial. /// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
tail: Vec<u8>, tail: Vec<u8>,
/// Reusable parity buffers for [`ErasureCoder::encode_into`] (plan Phase 1.4): grows once
/// to the session's high-water recovery count, then every block's parity is generated
/// into it with zero allocations.
recovery: Vec<Vec<u8>>,
} }
impl Packetizer { impl Packetizer {
@@ -163,7 +159,6 @@ impl Packetizer {
fec: config.fec, fec: config.fec,
version: config.phase as u8, version: config.phase as u8,
tail: Vec::new(), tail: Vec::new(),
recovery: Vec::new(),
} }
} }
@@ -267,7 +262,6 @@ impl Packetizer {
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]); self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
} }
let tail = &self.tail; let tail = &self.tail;
let recovery_pool = &mut self.recovery;
let shard_at = |s: usize| -> &[u8] { let shard_at = |s: usize| -> &[u8] {
if s < full_shards { if s < full_shards {
&frame[s * payload..(s + 1) * payload] &frame[s * payload..(s + 1) * payload]
@@ -285,8 +279,7 @@ impl Packetizer {
let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect(); let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect();
let recovery_count = self.fec.recovery_for(block_data_count); let recovery_count = self.fec.recovery_for(block_data_count);
coder.encode_into(&data_shards, recovery_count, recovery_pool)?; let recovery = coder.encode(&data_shards, recovery_count)?;
let recovery = &*recovery_pool;
let total_shards = block_data_count + recovery_count; let total_shards = block_data_count + recovery_count;
if total_shards > u16::MAX as usize { if total_shards > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16")); return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
@@ -338,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 {
@@ -367,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*
@@ -420,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 13
/// 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 {
@@ -460,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 {
@@ -474,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,
} }
} }
@@ -503,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;
@@ -543,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, win.advance_window(hdr.frame_index, hdr.pts_ns, stats, !is_probe);
recovery_pool,
in_flight_bytes,
lim.max_data_shards,
);
// Drop shards for frames already terminated (emitted — e.g. the recovery shards of a // 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 abandoned by // frame that completed early via the all-originals-present fast path) or that
// the loss window) and for frames that have fallen out of the loss window entirely. // have fallen out of the loss window.
if let Some(reconstructed) = win.completed.get_mut(&hdr.frame_index) { if win.completed.contains(&hdr.frame_index) || win.is_stale(hdr.frame_index, hdr.pts_ns) {
// 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); drop(stats);
return Ok(None); return Ok(None);
} }
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later // First packet of a frame establishes its geometry; later packets must agree.
// packets must agree with its geometry. let frame = win
let buf_len = total_data * shard_bytes; .frames
let frame = match win.frames.entry(hdr.frame_index) { .entry(hdr.frame_index)
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), .or_insert_with(|| FrameBuf {
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, frame_bytes,
block_count, block_count,
pts_ns: hdr.pts_ns, pts_ns: hdr.pts_ns,
user_flags: hdr.user_flags, user_flags: hdr.user_flags,
buf: vec![0; buf_len],
blocks: HashMap::new(), blocks: HashMap::new(),
blocks_ok: 0, 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 {
// First packet of a block sizes its shard vector; later packets must match its
// (data, recovery, shard_bytes) geometry, so `shard_index` is always in bounds.
frame
.blocks
.entry(hdr.block_index)
.or_insert_with(|| BlockBuf {
data_shards, data_shards,
recovery_shards, recovery_shards,
have_data: vec![false; data_shards], shard_bytes,
data_received: 0, shards: vec![None; total],
recovery: vec![None; recovery_shards], received: 0,
recovery_received: 0,
done: false, done: false,
reconstructed: false,
}); });
if block.recovery_shards != recovery_shards { 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 {
Ok(()) // every original arrived — its bytes are already in place
} else {
let base = block_idx * lim.max_data_shards * shard_bytes;
let region = &mut buf[base..base + block.data_shards * shard_bytes];
let mut slots: Vec<&mut [u8]> = region.chunks_mut(shard_bytes).collect();
let parity: Vec<(usize, &[u8])> = block
.recovery
.iter() .iter()
.enumerate() .filter(|s| s.is_some())
.filter_map(|(j, s)| s.as_deref().map(|b| (j, b))) .count();
.collect(); let recovered = match coder.reconstruct(
coder.reconstruct_into(block.recovery_shards, &mut slots, &block.have_data, &parity) block.data_shards,
}; block.recovery_shards,
// The parity buffers are spent either way — reclaim them for the next block. &mut block.shards,
for slot in block.recovery.iter_mut() { ) {
if let Some(rb) = slot.take() { Ok(r) => r,
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)
@@ -770,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.
@@ -825,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
}); });
@@ -848,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
@@ -1147,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);
+10 -17
View File
@@ -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;
+6 -13
View File
@@ -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]
+27 -345
View File
@@ -37,8 +37,7 @@ pub struct Frame {
pub struct Session { pub struct Session {
config: Config, config: Config,
coder: Box<dyn ErasureCoder>, coder: Box<dyn ErasureCoder>,
/// `Arc` so the second seal lane (Phase 1.5) can share the cipher; uncontended otherwise. crypto: Option<SessionCrypto>,
crypto: Option<std::sync::Arc<SessionCrypto>>,
/// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when /// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when
/// `crypto` is — the plaintext probe path carries no sequence to filter on. /// `crypto` is — the plaintext probe path carries no sequence to filter on.
replay: Option<ReplayWindow>, replay: Option<ReplayWindow>,
@@ -60,190 +59,19 @@ 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>,
/// Send-path stage timing (`PUNKTFUNK_PERF`), read+reset via [`take_seal_perf`]
/// (Self::take_seal_perf). Same arming + branch-cost contract as `perf`.
seal_perf: Option<SealPerf>,
/// The second seal lane (plan Phase 1.5), lazily spawned by the first frame that crosses
/// [`TWO_LANE_MIN_PACKETS`]. Host sessions only (client sessions never seal frames).
seal_lane: Option<SealLane>,
/// Two-lane sealing enabled (default). `PUNKTFUNK_SEAL_LANES=1` forces single-lane.
seal_two_lane: bool,
/// Reused header-Vec for the lane hand-off (the worker's half round-trips through this,
/// so steady-state two-lane frames move `n/2` Vec headers with zero allocation).
lane_scratch: Vec<Vec<u8>>,
} }
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5): /// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span /// pkt/s this is ~4k syscalls/s instead of 125k; the buffers cost `RECV_BATCH × RECV_BUF` (~64 KB).
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps const RECV_BATCH: usize = 32;
/// at 60 fps — small frames and the probe's ~17-packet AUs stay strictly single-lane.
const TWO_LANE_MIN_PACKETS: usize = 256;
/// One two-lane seal hand-off: the frame's back-half wire buffers, sealed by the worker with
/// nonces `seq_base + i` (the nonce order is deterministic per shard index, which is what
/// makes the split sound). Round-trips through the channels so the buffers return to the pool.
struct SealJob {
bufs: Vec<Vec<u8>>,
seq_base: u64,
timed: bool,
/// Worker-lane CPU ns (when `timed`) and the seal outcome, filled in by the worker.
ns: u64,
result: Result<()>,
}
/// The persistent second seal lane: a worker thread that AES-GCM-seals the back half of a
/// large frame's packets while the send thread seals the front half. Rendezvous channels
/// (bound 1) — the send thread submits, seals its half, then waits; no per-frame spawn.
/// Dropping the struct closes the channel and the worker exits.
struct SealLane {
to_worker: std::sync::mpsc::SyncSender<SealJob>,
from_worker: std::sync::mpsc::Receiver<SealJob>,
}
impl SealLane {
fn spawn(crypto: std::sync::Arc<SessionCrypto>) -> Option<SealLane> {
let (to_worker, jobs) = std::sync::mpsc::sync_channel::<SealJob>(1);
let (done_tx, from_worker) = std::sync::mpsc::sync_channel::<SealJob>(1);
std::thread::Builder::new()
.name("punktfunk-seal2".into())
.spawn(move || {
while let Ok(mut job) = jobs.recv() {
let t0 = job.timed.then(std::time::Instant::now);
job.result = seal_wire_slice(&crypto, &mut job.bufs, job.seq_base);
if let Some(t0) = t0 {
job.ns = t0.elapsed().as_nanos() as u64;
}
if done_tx.send(job).is_err() {
break; // session gone mid-frame — nothing left to seal for
}
}
})
.ok()?;
Some(SealLane {
to_worker,
from_worker,
})
}
}
/// Seal a run of pre-written wire buffers in place: buffer `i` is `seq(8) ‖ plaintext ‖ tag
/// scratch` and seals over `[8..]` with sequence `seq_base + i` — the exact per-packet layout
/// and nonce order of the fused single-lane path. Shared by both lanes.
fn seal_wire_slice(c: &SessionCrypto, wires: &mut [Vec<u8>], seq_base: u64) -> Result<()> {
for (i, wire) in wires.iter_mut().enumerate() {
c.seal_in_place(seq_base.wrapping_add(i as u64), &mut wire[8..])?;
}
Ok(())
}
/// Accumulated client receive-path stage timings since the last [`Session::take_pump_perf`].
/// Answers "where does the pump core go" at line rate: kernel drain (`recv_ns`) vs AES-GCM
/// (`decrypt_ns`) vs reassembly+FEC (`reasm_ns`, the `Reassembler::push` round-trip including
/// shard copies and block reconstruction). 2026-07-14 sweep context: the pump pegs one core at
/// ~1.5 Gbps wire, ~85% of it userspace — this split is what Phase 2.1 (pooled reassembly) is
/// validated against.
#[derive(Debug, Default, Clone, Copy)]
pub struct PumpPerf {
/// ns inside `recv_batch` (recvmmsg / recvmsg_x), i.e. syscall + kernel copy.
pub recv_ns: u64,
/// ns inside `open_in_place` across all datagrams (AES-128-GCM + replay-window upkeep).
pub decrypt_ns: u64,
/// ns inside `Reassembler::push` (header parse, shard copy, FEC reconstruct, AU assembly).
pub reasm_ns: u64,
/// recv_batch calls (batches) and datagrams processed over the accumulation window.
pub batches: u64,
pub packets: u64,
}
/// Accumulated host send-path stage timings since the last [`Session::take_seal_perf`] (plan
/// Phase 0.4, host half). Answers "where does the send thread go" at rate: FEC parity
/// generation (`fec_ns`, inside [`ErasureCoder::encode_into`]) vs AES-GCM (`seal_ns`,
/// per-packet `seal_in_place`) vs the socket handoff (`sock_ns` — `send_gso`/`sendmmsg`
/// syscalls; the internal submit paths time it here, the paced video path folds its chunk
/// sends in via [`Session::note_sock_ns`]). The Phase 1.5 gate reads off this split: build
/// two-lane seal only if `seal_ns` exceeds ~15% of the send thread at 2 Gbps.
#[derive(Debug, Default, Clone, Copy)]
pub struct SealPerf {
/// ns inside `ErasureCoder::encode_into` (parity generation).
pub fec_ns: u64,
/// ns inside `seal_in_place` across all wire packets (AES-128-GCM).
pub seal_ns: u64,
/// ns inside `send_sealed` (socket syscalls), where the session can see it.
pub sock_ns: u64,
/// Frames sealed and wire packets sealed over the accumulation window.
pub frames: u64,
pub packets: u64,
}
/// [`ErasureCoder`] shim accumulating the time spent in `encode_into` (the send-path FEC
/// stage) — only constructed when `PUNKTFUNK_PERF` armed the session's [`SealPerf`]. The
/// counter is atomic purely to satisfy the trait's `Sync` bound; it lives on one thread.
struct TimedCoder<'a> {
inner: &'a dyn ErasureCoder,
ns: &'a std::sync::atomic::AtomicU64,
}
impl ErasureCoder for TimedCoder<'_> {
fn scheme(&self) -> crate::config::FecScheme {
self.inner.scheme()
}
fn encode(
&self,
data: &[&[u8]],
recovery_count: usize,
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
self.inner.encode(data, recovery_count)
}
fn encode_into(
&self,
data: &[&[u8]],
recovery_count: usize,
out: &mut Vec<Vec<u8>>,
) -> std::result::Result<(), crate::fec::FecError> {
let t0 = std::time::Instant::now();
let r = self.inner.encode_into(data, recovery_count, out);
self.ns.fetch_add(
t0.elapsed().as_nanos() as u64,
std::sync::atomic::Ordering::Relaxed,
);
r
}
fn reconstruct(
&self,
data_count: usize,
recovery_count: usize,
received: &mut [Option<Vec<u8>>],
) -> std::result::Result<Vec<Vec<u8>>, crate::fec::FecError> {
self.inner.reconstruct(data_count, recovery_count, received)
}
fn reconstruct_into(
&self,
recovery_count: usize,
data: &mut [&mut [u8]],
have: &[bool],
recovery: &[(usize, &[u8])],
) -> std::result::Result<(), crate::fec::FecError> {
self.inner
.reconstruct_into(recovery_count, data, have, recovery)
}
}
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). 128 keeps
/// the syscall rate ≤ ~3.4k/s even at the ~430k pkt/s the post-2026-07-14 receive path delivers
/// (~4.8 Gbps wire), and gives the kernel buffer a deeper drain per pump iteration; the buffers
/// cost `RECV_BATCH × RECV_BUF` (~256 KB, client sessions only).
const RECV_BATCH: usize = 128;
impl Session { impl Session {
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> { pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
config.validate()?; config.validate()?;
let coder = coder_for(config.fec.scheme); let coder = coder_for(config.fec.scheme);
let crypto = config.encrypt.then(|| { let crypto = config
std::sync::Arc::new(SessionCrypto::new(&config.key, config.salt, config.role)) .encrypt
}); .then(|| SessionCrypto::new(&config.key, config.salt, config.role));
// A receive-side replay window exists exactly when the datagrams are sealed (they carry the // A receive-side replay window exists exactly when the datagrams are sealed (they carry the
// authenticated sequence the window keys on). Both roles receive from their peer. // authenticated sequence the window keys on). Both roles receive from their peer.
let replay = config.encrypt.then(ReplayWindow::new); let replay = config.encrypt.then(ReplayWindow::new);
@@ -263,45 +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),
seal_perf: std::env::var("PUNKTFUNK_PERF")
.is_ok_and(|v| v != "0")
.then(SealPerf::default),
seal_lane: None,
// Two-lane sealing of large frames is the default; =1 forces single-lane (the
// escape hatch — behavior is byte-identical, this only changes who seals).
seal_two_lane: std::env::var("PUNKTFUNK_SEAL_LANES")
.map(|v| v != "1")
.unwrap_or(true),
lane_scratch: Vec::new(),
config, config,
}) })
} }
/// Drain the receive-path stage timings accumulated since the last call (window semantics —
/// the pump reads this once per report interval). `None` when `PUNKTFUNK_PERF` is off.
pub fn take_pump_perf(&mut self) -> Option<PumpPerf> {
self.perf.as_mut().map(std::mem::take)
}
/// Drain the send-path stage timings accumulated since the last call (window semantics —
/// the host send loop reads this once per perf window). `None` when `PUNKTFUNK_PERF` is off.
pub fn take_seal_perf(&mut self) -> Option<SealPerf> {
self.seal_perf.as_mut().map(std::mem::take)
}
/// Fold externally-timed socket time into [`SealPerf::sock_ns`] — the paced video path
/// times its own `send_sealed` chunk calls (they happen behind a `&self` borrow inside the
/// pacing closure, where the session can't self-time). No-op when perf is off.
pub fn note_sock_ns(&mut self, ns: u64) {
if let Some(p) = self.seal_perf.as_mut() {
p.sock_ns += ns;
}
}
pub fn role(&self) -> Role { pub fn role(&self) -> Role {
self.config.role self.config.role
} }
@@ -406,40 +199,18 @@ impl Session {
// nonce counter advances per emitted packet exactly as before (pinned by the // nonce counter advances per emitted packet exactly as before (pinned by the
// wire-equivalence tests below). Destructure into disjoint field borrows first — the // wire-equivalence tests below). Destructure into disjoint field borrows first — the
// emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`. // emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
let perf_armed = self.seal_perf.is_some();
let fec_ns = std::sync::atomic::AtomicU64::new(0);
let mut seal_ns = 0u64;
let two_lane = self.seal_two_lane;
let Session { let Session {
packetizer, packetizer,
coder, coder,
crypto, crypto,
next_seq, next_seq,
wire_pool, wire_pool,
seal_lane,
lane_scratch,
.. ..
} = self; } = self;
// Stage timing (SealPerf): the coder shim times FEC, the seal phase times itself.
let timed_coder;
let coder_ref: &dyn ErasureCoder = if perf_armed {
timed_coder = TimedCoder {
inner: coder.as_ref(),
ns: &fec_ns,
};
&timed_coder
} else {
coder.as_ref()
};
let mut wires = std::mem::take(wire_pool); let mut wires = std::mem::take(wire_pool);
let mut used = 0usize; let mut used = 0usize;
// Phase 1 — packetize: write each packet's plaintext at its final wire offset let result =
// (`seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16)` with crypto on; `header ‖ shard` packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder.as_ref(), {
// off). The nonce counter advances per packet in emission order exactly as before;
// sealing itself is a separate pass so it can split across lanes.
let seq_base = *next_seq;
let encrypting = crypto.is_some();
let result = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, {
let wires = &mut wires; let wires = &mut wires;
let used = &mut used; let used = &mut used;
move |hdr, body| { move |hdr, body| {
@@ -451,79 +222,27 @@ impl Session {
let seq = *next_seq; let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1); *next_seq = next_seq.wrapping_add(1);
wire.clear(); wire.clear();
if encrypting { match crypto {
Some(c) => {
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
wire.extend_from_slice(&seq.to_be_bytes()); wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(hdr.as_bytes()); wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body); wire.extend_from_slice(body);
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0); wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
} else { c.seal_in_place(seq, &mut wire[8..])?;
}
None => {
wire.extend_from_slice(hdr.as_bytes()); wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body); wire.extend_from_slice(body);
} }
}
Ok(()) Ok(())
} }
}); });
result?; result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same // A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a // as the previous `resize_with(packets.len(), ..)` did.
// two-lane split hands the worker exactly the frame's back half.)
wires.truncate(used); wires.truncate(used);
// Phase 2 — seal. Large frames split across two lanes (plan Phase 1.5): the worker
// seals the back half under nonces `seq_base + i` while this thread seals the front —
// byte-identical output to the sequential pass (pinned by the wire-equivalence test).
if let Some(c) = crypto {
if two_lane && used >= TWO_LANE_MIN_PACKETS && seal_lane.is_none() {
*seal_lane = SealLane::spawn(c.clone()); // stays None if spawn fails → single-lane
}
let mut split_done = false;
if two_lane && used >= TWO_LANE_MIN_PACKETS {
if let Some(lane) = seal_lane.as_ref() {
let half = used / 2;
let mut tail = std::mem::take(lane_scratch);
tail.extend(wires.drain(half..));
let job = SealJob {
bufs: tail,
seq_base: seq_base.wrapping_add(half as u64),
timed: perf_armed,
ns: 0,
result: Ok(()),
};
if lane.to_worker.send(job).is_ok() {
// Seal the front half while the worker runs; collect BOTH results
// before erroring so the lane is always drained and reusable.
let t0 = perf_armed.then(std::time::Instant::now);
let front = seal_wire_slice(c, &mut wires, seq_base);
if let Some(t0) = t0 {
seal_ns += t0.elapsed().as_nanos() as u64;
}
let mut done = lane
.from_worker
.recv()
.map_err(|_| PunktfunkError::Unsupported("seal lane died"))?;
seal_ns += done.ns;
wires.append(&mut done.bufs);
*lane_scratch = done.bufs;
front?;
done.result?;
split_done = true;
}
// A failed send means the worker is gone — fall through to single-lane.
}
}
if !split_done {
let t0 = perf_armed.then(std::time::Instant::now);
seal_wire_slice(c, &mut wires, seq_base)?;
if let Some(t0) = t0 {
seal_ns += t0.elapsed().as_nanos() as u64;
}
}
}
if let Some(p) = self.seal_perf.as_mut() {
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
p.seal_ns += seal_ns;
p.frames += 1;
p.packets += used as u64;
}
StatsCounters::add(&self.stats.frames_submitted, 1); StatsCounters::add(&self.stats.frames_submitted, 1);
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum(); let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64); StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
@@ -559,12 +278,8 @@ impl Session {
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> { pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
let wires = self.seal_frame(data, pts_ns, user_flags)?; let wires = self.seal_frame(data, pts_ns, user_flags)?;
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
let r = self.send_sealed(&refs); let r = self.send_sealed(&refs);
drop(refs); // release the borrow of `wires` before returning the buffers to the pool drop(refs); // release the borrow of `wires` before returning the buffers to the pool
if let Some(t0) = t0 {
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
}
self.reclaim_wires(wires); self.reclaim_wires(wires);
r.map(|_| ()) r.map(|_| ())
} }
@@ -580,12 +295,8 @@ impl Session {
let wires = let wires =
self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?; self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?;
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
let t0 = self.seal_perf.is_some().then(std::time::Instant::now);
let r = self.send_sealed(&refs); let r = self.send_sealed(&refs);
drop(refs); drop(refs);
if let Some(t0) = t0 {
self.note_sock_ns(t0.elapsed().as_nanos() as u64);
}
self.reclaim_wires(wires); self.reclaim_wires(wires);
r.map(|_| ()) r.map(|_| ())
} }
@@ -651,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);
@@ -678,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.
@@ -695,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.
@@ -712,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);
} }
@@ -739,8 +433,8 @@ impl Session {
/// (observed live: a stream stuck 67 s behind, socket buffers full end to end). Discarding /// (observed live: a stream stuck 67 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(
@@ -752,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)?;
@@ -798,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
@@ -950,7 +642,6 @@ mod wire_equivalence_tests {
pattern(100), // single block, partial tail pattern(100), // single block, partial tail
Vec::new(), // empty frame → 1 zeroed shard Vec::new(), // empty frame → 1 zeroed shard
pattern(64), // exactly one full shard pattern(64), // exactly one full shard
pattern(20000), // > TWO_LANE_MIN_PACKETS wire packets → two-lane seal
]; ];
for (i, frame) in frames.iter().enumerate() { for (i, frame) in frames.iter().enumerate() {
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap(); let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
@@ -963,15 +654,6 @@ mod wire_equivalence_tests {
// (including a bigger frame after a smaller one and vice versa). // (including a bigger frame after a smaller one and vice versa).
opt.reclaim_wires(got); opt.reclaim_wires(got);
} }
// The 20000-byte frame (~469 wire packets at shard 64) crosses
// TWO_LANE_MIN_PACKETS: the equality above must have held THROUGH the
// two-lane split, not via a silent single-lane fallback.
if encrypt {
assert!(
opt.seal_lane.is_some(),
"two-lane seal lane should have spawned for the large frame"
);
}
} }
} }
} }
-9
View File
@@ -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),
} }
+1 -2
View File
@@ -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> {
+5 -18
View File
@@ -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");
}
} }
} }
-27
View File
@@ -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(());
+29 -241
View File
@@ -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
@@ -49,9 +49,6 @@ pub struct VideoPacketizer {
frame_index: u32, frame_index: u32,
/// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source). /// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source).
seq: u32, seq: u32,
/// Persistent GF(2⁸) coder so its `(k, m)` Cauchy-matrix cache survives across frames
/// (plan Phase 1.4) — a stream's block shape only moves with frame size.
coder: Gf8Coder,
} }
impl VideoPacketizer { impl VideoPacketizer {
@@ -68,7 +65,6 @@ impl VideoPacketizer {
min_fec: min_fec as usize, min_fec: min_fec as usize,
frame_index: 0, frame_index: 0,
seq: 0, seq: 0,
coder: Gf8Coder::default(),
} }
} }
@@ -162,7 +158,7 @@ impl VideoPacketizer {
let wire_pct = if m > 0 { (100 * m) / k } else { 0 }; let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
let parity = if m > 0 { let parity = if m > 0 {
let refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect(); let refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect();
self.coder.encode(&refs, m).unwrap_or_default() Gf8Coder.encode(&refs, m).unwrap_or_default()
} else { } else {
Vec::new() Vec::new()
}; };
@@ -332,9 +328,7 @@ mod tests {
// Drop data shard 1; reconstruct from the rest via the same Cauchy coder. // Drop data shard 1; reconstruct from the rest via the same Cauchy coder.
let mut received: Vec<Option<Vec<u8>>> = pkts.iter().map(|p| Some(p.clone())).collect(); let mut received: Vec<Option<Vec<u8>>> = pkts.iter().map(|p| Some(p.clone())).collect();
received[1] = None; received[1] = None;
let recovered = Gf8Coder::default() let recovered = Gf8Coder.reconstruct(k, m, &mut received).unwrap();
.reconstruct(k, m, &mut received)
.unwrap();
// The recovered shard equals the original data shard's RS-covered bytes: its flags // The recovered shard equals the original data shard's RS-covered bytes: its flags
// byte (offset 24) is PIC (middle shard), proving the NV header recovers correctly. // byte (offset 24) is PIC (middle shard), proving the NV header recovers correctly.
assert_eq!(recovered[1][24], FLAG_PIC); assert_eq!(recovered[1][24], FLAG_PIC);
+16 -22
View File
@@ -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
}
} }
@@ -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.
@@ -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>;
+3 -11
View File
@@ -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(),
+41 -111
View File
@@ -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,15 +3226,11 @@ 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),
}; };
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
let mut sock_ns = 0u64;
let result = crate::send_pacing::pace_frame( let result = crate::send_pacing::pace_frame(
&refs, &refs,
crate::send_pacing::PaceBudget::UntilDeadline { crate::send_pacing::PaceBudget::UntilDeadline {
@@ -3268,16 +3238,10 @@ fn paced_submit(
fraction: 0.9, fraction: 0.9,
}, },
&cfg, &cfg,
|chunk| { |chunk| session.send_sealed(chunk).map(|_| ()),
let t0 = std::time::Instant::now();
let r = session.send_sealed(chunk).map(|_| ());
sock_ns += t0.elapsed().as_nanos() as u64;
r
},
); );
drop(refs); // release the borrow of `wires` so it can return to the seal pool drop(refs); // release the borrow of `wires` so it can return to the seal pool
session.reclaim_wires(wires); session.reclaim_wires(wires);
session.note_sock_ns(sock_ns);
result.map_err(|e| anyhow!("send_sealed: {e:?}")) result.map_err(|e| anyhow!("send_sealed: {e:?}"))
} }
@@ -3479,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
@@ -3594,11 +3558,6 @@ fn send_loop(
// Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire. // Attempted (sealed) transmit rate; `send_dropped` is what didn't reach the wire.
let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0; let tx_mbps = (s.bytes_sent - last_bytes) as f64 * 8.0 / secs / 1_000_000.0;
if perf { if perf {
// Send-thread stage split (Phase 0.4 host half): busy-time sums over this
// window, so share-of-core = <stage>_ms / window wall ms. The per-packet ns
// figures are the Phase 1.5 gate metric — seal parallelism is warranted only
// if seal_ns_pp × pkts/s approaches ~15% of a core at 2 Gbps.
let sp = session.take_seal_perf().unwrap_or_default();
tracing::info!( tracing::info!(
tx_mbps = format!("{tx_mbps:.0}"), tx_mbps = format!("{tx_mbps:.0}"),
send_dropped = s.packets_send_dropped - last_send_dropped, send_dropped = s.packets_send_dropped - last_send_dropped,
@@ -3610,14 +3569,6 @@ fn send_loop(
pace_us_max = pace_us.last().copied().unwrap_or(0), pace_us_max = pace_us.last().copied().unwrap_or(0),
immediate_frames, immediate_frames,
paced_frames, paced_frames,
window_ms = format!("{:.0}", secs * 1000.0),
fec_ms = format!("{:.2}", sp.fec_ns as f64 / 1e6),
seal_ms = format!("{:.2}", sp.seal_ns as f64 / 1e6),
sock_ms = format!("{:.2}", sp.sock_ns as f64 / 1e6),
fec_ns_pp = sp.fec_ns.checked_div(sp.packets).unwrap_or(0),
seal_ns_pp = sp.seal_ns.checked_div(sp.packets).unwrap_or(0),
sock_ns_pp = sp.sock_ns.checked_div(sp.packets).unwrap_or(0),
sealed_pkts = sp.packets,
"perf" "perf"
); );
} }
@@ -4005,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
@@ -4303,34 +4253,16 @@ 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!(
from_kbps = bitrate_kbps,
to_kbps = new_kbps,
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
);
bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed);
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
} else {
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
// rate.
let hz = interval_hz(interval); let hz = interval_hz(interval);
match crate::encode::open_video( match crate::encode::open_video(
plan.codec, plan.codec,
@@ -4352,9 +4284,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
enc = new_enc; enc = new_enc;
bitrate_kbps = new_kbps; bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed); live_bitrate.store(new_kbps, Ordering::Relaxed);
// The owed AUs died with the old encoder — same bookkeeping as a // The owed AUs died with the old encoder — same bookkeeping as a mode-switch
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the // rebuild; the fresh encoder opens on an IDR, so anchor the IDR cooldown too.
// IDR cooldown too.
inflight.clear(); inflight.clear();
last_au_at = std::time::Instant::now(); last_au_at = std::time::Instant::now();
encoder_resets = 0; encoder_resets = 0;
@@ -4366,7 +4297,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
} }
} }
} }
}
// Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening // Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening
// GOP). Coalesce the backlog — several requests fire before the IDR lands — and force // GOP). Coalesce the backlog — several requests fire before the IDR lands — and force
// the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with // the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with
@@ -5443,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!(
@@ -5470,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 -110
View File
@@ -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,15 +257,12 @@ 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)] {
let s = schedule(&pkts, &native_cfg(cap), budget);
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split"); assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size"); assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count"); assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
} }
} }
}
/// Deterministic-schedule pin, GameStream plane: no burst stage, and the chunk/step layout /// Deterministic-schedule pin, GameStream plane: no burst stage, and the chunk/step layout
/// must reproduce the legacy `pace_layout` exactly (chunk = max(16, ceil(n/12)), ≤ 12 /// must reproduce the legacy `pace_layout` exactly (chunk = max(16, ceil(n/12)), ≤ 12
@@ -322,9 +276,7 @@ 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)] {
let s = schedule(&pkts, &gs_cfg(), budget);
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage"); assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
assert_eq!(s.chunk, chunk, "n={n}: chunk size"); assert_eq!(s.chunk, chunk, "n={n}: chunk size");
assert_eq!(s.steps, steps, "n={n}: step count"); assert_eq!(s.steps, steps, "n={n}: step count");
@@ -332,43 +284,12 @@ mod tests {
assert!(s.chunk >= 16, "n={n}: chunk floor"); assert!(s.chunk >= 16, "n={n}: chunk floor");
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets"); 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!(
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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
+1 -4
View File
@@ -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
``` ```
--- ---
+36 -103
View File
@@ -341,19 +341,6 @@ fn channel_cfg() -> ChannelConfig {
} }
} }
/// The wire pad index the host stamped into the sealed section (0 while the channel hasn't
/// attached yet). Keys every per-pad identity surface: the Deck unit id + serial, the PS
/// identities' pairing MAC (feature 0x09/0x12) and USB serial string — SDL/Steam dedup
/// controllers by serial, so two virtual pads must never share one (identical serials make a
/// second pad read as the FIRST one re-appearing over another transport, and it is merged).
fn pad_index() -> u8 {
(CHANNEL
.data()
.map(|v| v.read_u32(OFF_PAD_INDEX))
.unwrap_or(0)
& 0xFF) as u8
}
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds, /// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit /// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report /// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
@@ -627,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
@@ -650,61 +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 — matches let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
// steam_proto::deck_unit_id / deck_serial, so two virtual Decks never collide in Steam's eyes. let serial = b"PFDK50460003";
let unit_id: u32 = 0x5046_0000 | pad_index() as u32;
// 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),
@@ -717,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),
} }
@@ -753,23 +703,12 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
// DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
// for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
// 0x02/0x12/0xa3. // 0x02/0x12/0xa3.
// The pairing replies are per-pad: the MAC (bytes 1..7, LSB first) low octet carries the pad let blob: &[u8] = match (device_type(), report_id) {
// index (see `pad_index` — SDL/Steam dedup controllers by this serial), agreeing with the
// GET_STRING serial in `on_get_string`. The Edge lands on its GET_STRING base (0x75 = DS
// base + 1) so its feature MAC and USB serial string agree too.
let devtype = device_type();
let mut ds_pairing = DS_FEATURE_PAIRING;
ds_pairing[1] = ds_pairing[1]
.wrapping_add(u8::from(devtype == 2))
.wrapping_add(pad_index());
let mut ds4_pairing = DS4_FEATURE_PAIRING;
ds4_pairing[1] = ds4_pairing[1].wrapping_add(pad_index());
let blob: &[u8] = match (devtype, report_id) {
(0 | 2, 0x05) => &DS_FEATURE_CALIBRATION, (0 | 2, 0x05) => &DS_FEATURE_CALIBRATION,
(0 | 2, 0x09) => &ds_pairing, (0 | 2, 0x09) => &DS_FEATURE_PAIRING,
(0 | 2, 0x20) => &DS_FEATURE_FIRMWARE, (0 | 2, 0x20) => &DS_FEATURE_FIRMWARE,
(1, 0x02) => &DS4_FEATURE_CALIBRATION, (1, 0x02) => &DS4_FEATURE_CALIBRATION,
(1, 0x12) => &ds4_pairing, (1, 0x12) => &DS4_FEATURE_PAIRING,
(1, 0xA3) => &DS4_FEATURE_FIRMWARE, (1, 0xA3) => &DS4_FEATURE_FIRMWARE,
(_, other) => { (_, other) => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
@@ -797,28 +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",
}, },
// Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and
// Steam dedups controllers by it. The PS strings are the pairing MAC MSB-first, so the
// low octet — the LAST two hex chars — carries the pad index, agreeing with the patched
// feature 0x09/0x12 replies in `on_get_feature`. The Deck serial must agree with
// deck_feature_reply's 0xAE answer (Steam reads both).
2 | 0x0010 => match devtype { 2 | 0x0010 => match devtype {
1 => format!("DEADBEEF00{:02X}", 0x01u8.wrapping_add(pad_index())), 1 => "DEADBEEF0001",
2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())), 2 => "35533AD6E775",
3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32), 3 => "PFDK50460003",
_ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())), _ => "35533AD6E774",
}, },
_ => 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);
@@ -830,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
-21
View File
@@ -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"
-9
View File
@@ -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"
-37
View File
@@ -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
-356
View File
@@ -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())