Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4c9e44cc | |||
| ac60db5410 | |||
| 6b9f261dff | |||
| 75b3c94f60 | |||
| b8da32e8b6 | |||
| c4645a8938 | |||
| 252960291e | |||
| 01266ff18d | |||
| b50b698078 | |||
| 762a627df9 | |||
| a93f5a71ce | |||
| a7d4a93ff2 | |||
| 499bf2dae8 | |||
| ff38933312 | |||
| 56f9c8c4b4 | |||
| 5249d31dfa | |||
| 694bec4ead | |||
| e1d7fa2a30 | |||
| 7b9337c7d0 | |||
| 0325e1cf6f | |||
| e807ffbff8 | |||
| 6425edb8e4 | |||
| f24379c2f8 | |||
| a40ae49cf8 | |||
| 7f1680b043 | |||
| a959e731da | |||
| 81edd27155 | |||
| 4d2cc2a3a7 | |||
| d352e4e456 | |||
| 739a5f76bf | |||
| a70811043e | |||
| 9127c3465f | |||
| 2621b6e6b1 | |||
| 705a8baddf | |||
| 1fc9ef0050 | |||
| 12148243bd | |||
| 8dc5d672e2 | |||
| 719b1ef403 | |||
| eb8a659319 | |||
| fa4df1de9e | |||
| ef862454b0 | |||
| f77eec1299 | |||
| 575975687c | |||
| 49ba1cd11b | |||
| e71cb9b7bd | |||
| 9724fb4a4e | |||
| 767f028bdf | |||
| 4c3b11445c |
Generated
+26
-14
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,9 +2756,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
@@ -2766,6 +2767,7 @@ dependencies = [
|
||||
"pf-ffvk",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2778,7 +2780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,7 +3041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3058,7 +3060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3089,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3117,9 +3119,11 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"parking_lot",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
@@ -3161,7 +3165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3175,7 +3179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3190,6 +3194,14 @@ dependencies = [
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/pf-console-ui",
|
||||
"crates/pf-ffvk",
|
||||
"crates/pf-driver-proto",
|
||||
"crates/pyrowave-sys",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/session",
|
||||
@@ -35,7 +36,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+679
-297
File diff suppressed because it is too large
Load Diff
+27
-7
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.9.1"
|
||||
"version": "0.11.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2043,11 +2043,12 @@
|
||||
},
|
||||
"ApiCodec": {
|
||||
"type": "string",
|
||||
"description": "Video codec identifier.",
|
||||
"description": "Video codec identifier. The wire token matches the codec's canonical name used across the\nstack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.",
|
||||
"enum": [
|
||||
"h264",
|
||||
"h265",
|
||||
"av1"
|
||||
"hevc",
|
||||
"av1",
|
||||
"pyrowave"
|
||||
]
|
||||
},
|
||||
"ApiDisplayInfo": {
|
||||
@@ -2811,6 +2812,7 @@
|
||||
"app_version",
|
||||
"gfe_version",
|
||||
"codecs",
|
||||
"gamestream",
|
||||
"ports"
|
||||
],
|
||||
"properties": {
|
||||
@@ -2831,6 +2833,10 @@
|
||||
},
|
||||
"description": "Codecs the host can encode (NVENC)."
|
||||
},
|
||||
"gamestream": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)."
|
||||
},
|
||||
"gfe_version": {
|
||||
"type": "string",
|
||||
"description": "GFE version advertised to Moonlight clients."
|
||||
@@ -3393,9 +3399,16 @@
|
||||
"video_streaming",
|
||||
"audio_streaming",
|
||||
"pin_pending",
|
||||
"paired_clients"
|
||||
"paired_clients",
|
||||
"active_sessions"
|
||||
],
|
||||
"properties": {
|
||||
"active_sessions": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.",
|
||||
"minimum": 0
|
||||
},
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
@@ -3417,7 +3430,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
"description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3428,7 +3441,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/StreamInfo",
|
||||
"description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)."
|
||||
"description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3599,6 +3612,7 @@
|
||||
"armed",
|
||||
"sample_count",
|
||||
"started_unix_ms",
|
||||
"elapsed_ms",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
@@ -3606,6 +3620,12 @@
|
||||
"type": "boolean",
|
||||
"description": "Capture currently running."
|
||||
},
|
||||
"elapsed_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Path of the in-progress capture (`\"\"` if idle)."
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Gamepad rumble feedback. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
|
||||
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
|
||||
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
|
||||
devices without a gamepad. -->
|
||||
@@ -40,6 +44,10 @@
|
||||
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
|
||||
direct-BLE pad — both optional (the feature quietly disengages without them). -->
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
|
||||
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
|
||||
@@ -65,10 +73,16 @@
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
|
||||
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
|
||||
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
|
||||
Android RECREATES the activity, disposing StreamScreen and killing the stream the
|
||||
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
|
||||
interfaces at session end brings the devices back — same flip, same need. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
|
||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
|
||||
android:theme="@style/Theme.PunktfunkAndroid">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -303,7 +303,8 @@ internal fun PairPinDialog(
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp) // verified host fp — caller saves + connects
|
||||
} else {
|
||||
err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/**
|
||||
* Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable
|
||||
* machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN
|
||||
* ceremony and the request-access (delegated approval) path, so a dead network path is never
|
||||
* reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact
|
||||
* collapse behind more than one support thread.
|
||||
*/
|
||||
object ConnectErrors {
|
||||
/** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */
|
||||
fun pairMessage(token: String): String = when (token) {
|
||||
"crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again."
|
||||
else -> shared(token) ?: transport(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`).
|
||||
* [requestAccess] tunes the fallback wording for the delegated-approval path.
|
||||
*/
|
||||
fun connectMessage(token: String, requestAccess: Boolean): String =
|
||||
shared(token) ?: when (token) {
|
||||
"crypto" ->
|
||||
"The host's identity doesn't match the saved fingerprint — re-pair with this host."
|
||||
"timeout", "io", "" ->
|
||||
if (requestAccess) {
|
||||
"The request never reached the host, or nobody approved it in time — " +
|
||||
"check the network path (no VPN, no guest-Wi-Fi isolation) and the " +
|
||||
"host's console."
|
||||
} else {
|
||||
transport(token)
|
||||
}
|
||||
else -> "Connection failed — check host/port and logcat."
|
||||
}
|
||||
|
||||
/** The host's typed rejection reasons — identical wording across every punktfunk client. */
|
||||
private fun shared(token: String): String? = when (token) {
|
||||
"not-armed" ->
|
||||
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try again."
|
||||
"bound-other" ->
|
||||
"The host's pairing window is armed for a different device — arm it for this one."
|
||||
"rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again."
|
||||
"identity-required" ->
|
||||
"The host requires pairing — pair this device (PIN or request access) first."
|
||||
"denied" -> "The host declined this device's request."
|
||||
"approval-timeout" ->
|
||||
"Nobody approved the request on the host in time — approve this device in the " +
|
||||
"host's console or web UI, then request access again."
|
||||
"superseded" ->
|
||||
"A newer request from this device replaced this one — approve the latest request " +
|
||||
"on the host."
|
||||
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
||||
"busy" -> "The host is busy with another session."
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Transport-level causes (nothing typed arrived from the host). */
|
||||
private fun transport(token: String): String = when (token) {
|
||||
"timeout" ->
|
||||
"The host didn't answer — check that this device and the host are on the same " +
|
||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
"io" ->
|
||||
"Couldn't reach the host — check that this device and the host are on the same " +
|
||||
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)."
|
||||
}
|
||||
}
|
||||
@@ -305,13 +305,17 @@ fun ConnectScreen(
|
||||
onConnected(handle)
|
||||
} else {
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||
// `waker.waking` here land in one recompose, so the overlay slides
|
||||
val token = NativeBridge.nativeTakeLastError()
|
||||
val unreachable = token == "timeout" || token == "io" || token.isEmpty()
|
||||
if (onFailure != null && unreachable) {
|
||||
// Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above
|
||||
// and setting `waker.waking` here land in one recompose, so the overlay slides
|
||||
// Connecting → Waking without a blank frame.
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
// A typed host rejection (busy / versions differ / pairing required) means the
|
||||
// host is awake — waking it would be nonsense; show the stated reason instead.
|
||||
status = ConnectErrors.connectMessage(token, requestAccess = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +420,12 @@ fun ConnectScreen(
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Request timed out — approve this device in the host's console, then retry."
|
||||
// Cause-specific: an operator denial, an approval timeout, and a request that
|
||||
// never reached the host are different problems with different fixes.
|
||||
status = ConnectErrors.connectMessage(
|
||||
NativeBridge.nativeTakeLastError(),
|
||||
requestAccess = true,
|
||||
)
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
|
||||
Group("Gamepads") {
|
||||
if (pads.isEmpty()) {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
"classifies as a gamepad or joystick — a pad connected through an adapter " +
|
||||
@@ -214,6 +246,79 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
|
||||
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
|
||||
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
|
||||
*/
|
||||
@Composable
|
||||
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().sc2Capture }
|
||||
val active = activity?.sc2MenuActive == true
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Steam Controller 2",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (active) {
|
||||
Text(
|
||||
"navigating this UI",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when {
|
||||
usbDev == null -> "Paired via Bluetooth"
|
||||
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
|
||||
else -> "Puck dongle (USB)"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
|
||||
"passthrough\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
active -> Text(
|
||||
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
|
||||
"that its Steam drives directly (trackpads, gyro, haptics).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
usbDev != null && !permitted -> {
|
||||
Text(
|
||||
"Needs USB access to be captured.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
"Detected — capture engages automatically.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
@@ -389,6 +494,8 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
|
||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
|
||||
else -> "Automatic"
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,12 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
|
||||
}
|
||||
pairing = false
|
||||
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
|
||||
if (fp.isNotEmpty()) {
|
||||
onPaired(fp)
|
||||
} else {
|
||||
// Cause-specific: wrong PIN vs not-armed vs unreachable.
|
||||
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean {
|
||||
@Composable
|
||||
fun rememberControllerConnected(): State<Boolean> {
|
||||
val context = LocalContext.current
|
||||
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
|
||||
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
|
||||
// and the claim removes even those) — the InputManager path below can't see it.
|
||||
val activity = context as? MainActivity
|
||||
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
|
||||
DisposableEffect(Unit) {
|
||||
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
@@ -59,5 +64,7 @@ fun rememberControllerConnected(): State<Boolean> {
|
||||
connected.value = Gamepad.firstPad() != null
|
||||
onDispose { im.unregisterInputDeviceListener(listener) }
|
||||
}
|
||||
return connected
|
||||
return remember {
|
||||
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
@@ -21,6 +28,9 @@ import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||
@@ -74,6 +84,30 @@ class MainActivity : ComponentActivity() {
|
||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||
private var highRefreshModeId = 0
|
||||
|
||||
/**
|
||||
* Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces
|
||||
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
|
||||
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
|
||||
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
|
||||
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
|
||||
* Controllers screen.
|
||||
*/
|
||||
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
|
||||
var sc2MenuActive by mutableStateOf(false)
|
||||
private set
|
||||
private var sc2Receiver: BroadcastReceiver? = null
|
||||
private var sc2PermissionAsked = false
|
||||
|
||||
/**
|
||||
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
|
||||
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
|
||||
* ViewRootImpl's focus-navigation stage — the one that grants initial focus for a real
|
||||
* pad's first D-pad press — so on a phone in touch mode it lands on a focus-less window
|
||||
* and does nothing (first on-glass run: only B worked, since it bypasses key events
|
||||
* entirely). `FocusManager.moveFocus` is the public API for exactly this.
|
||||
*/
|
||||
private var sc2MoveFocus: ((androidx.compose.ui.focus.FocusDirection) -> Boolean)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lastPadIsGamepad = !isTvDevice(this)
|
||||
@@ -91,13 +125,166 @@ class MainActivity : ComponentActivity() {
|
||||
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
|
||||
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
|
||||
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
|
||||
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
sc2PermissionAsked = false // a fresh attach may ask once again
|
||||
startSc2MenuNav()
|
||||
}
|
||||
SC2_MENU_PERMISSION -> {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
startSc2MenuNav()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sc2Receiver = receiver
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
addAction(SC2_MENU_PERMISSION)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(receiver, filter)
|
||||
}
|
||||
setContent {
|
||||
PunktfunkTheme {
|
||||
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
|
||||
// the bootstrap: directional moves need an already-focused node, while one-
|
||||
// dimensional traversal assigns initial focus when there is none.
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
sc2MoveFocus = { dir ->
|
||||
focusManager.moveFocus(dir) ||
|
||||
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
|
||||
}
|
||||
onDispose { sc2MoveFocus = null }
|
||||
}
|
||||
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startSc2MenuNav()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
|
||||
stopSc2MenuNav()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
|
||||
sc2Receiver = null
|
||||
stopSc2MenuNav()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
|
||||
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
|
||||
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
|
||||
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
|
||||
*/
|
||||
fun startSc2MenuNav(forceAsk: Boolean = false) {
|
||||
if (forceAsk) sc2PermissionAsked = false
|
||||
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
|
||||
if (sc2Menu?.isActive == true) return
|
||||
if (!SettingsStore(this).load().sc2Capture) return
|
||||
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
|
||||
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
|
||||
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
|
||||
sc2Menu = c
|
||||
}
|
||||
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val dev = cap.findUsbDevice()
|
||||
when {
|
||||
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
|
||||
dev != null && !sc2PermissionAsked -> {
|
||||
sc2PermissionAsked = true
|
||||
usbManager.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
this, 1,
|
||||
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
cap.pairedBleAddress()?.let { cap.startBle(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
|
||||
fun stopSc2MenuNav() {
|
||||
sc2Menu?.stop()
|
||||
sc2MenuActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
|
||||
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
|
||||
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
|
||||
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
|
||||
*/
|
||||
private fun sc2NavKey(keyCode: Int, down: Boolean) {
|
||||
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
|
||||
lastPadIsGamepad = true
|
||||
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
|
||||
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
|
||||
// The console UI navigates through padKeyProbe (GamepadNavEffect's held-state + repeat
|
||||
// machinery — A/X/Y/D-pad/Select), NOT the focus system: synthesized events must be
|
||||
// offered there first, exactly like real ones in dispatchKeyEvent (tester-diagnosed:
|
||||
// routing everything via super.dispatchKeyEvent bypassed the probe, so only B — which
|
||||
// never rides key events — did anything). The probes gate on keycode only, so a
|
||||
// synthetic KeyEvent satisfies them.
|
||||
padKeyProbe?.let { if (it(KeyEvent(action, keyCode))) return }
|
||||
when (keyCode) {
|
||||
// B → back, on release (same edge the real-pad path uses).
|
||||
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
|
||||
// A → activate the focused element (the focus system understands DPAD_CENTER; the
|
||||
// Compose node focused via the moveFocus hook receives it once the ComposeView
|
||||
// holds view-focus).
|
||||
KeyEvent.KEYCODE_BUTTON_A ->
|
||||
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
|
||||
// D-pad → Compose's own focus API (a synthetic DPAD KeyEvent can't grant initial
|
||||
// focus — see [sc2MoveFocus]); one move per press edge.
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Up)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Down)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Left)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Right)
|
||||
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveSc2Focus(dir: androidx.compose.ui.focus.FocusDirection) {
|
||||
val hook = sc2MoveFocus
|
||||
if (hook == null || !hook(dir)) {
|
||||
// No composition hook (shouldn't happen) — fall back to the raw key dispatch.
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dirToKey(dir)))
|
||||
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dirToKey(dir)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun dirToKey(dir: androidx.compose.ui.focus.FocusDirection): Int = when (dir) {
|
||||
androidx.compose.ui.focus.FocusDirection.Up -> KeyEvent.KEYCODE_DPAD_UP
|
||||
androidx.compose.ui.focus.FocusDirection.Down -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
androidx.compose.ui.focus.FocusDirection.Left -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
private fun resolveHighRefreshMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -130,9 +317,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (bit != 0) {
|
||||
// The router forwards the bit on this device's own wire pad index and tracks held
|
||||
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
|
||||
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
|
||||
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
|
||||
// accidental brush of the four buttons no longer quits instantly.
|
||||
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
|
||||
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
|
||||
// can still leave — but an accidental brush of the four buttons no longer quits.
|
||||
gamepadRouter?.onButton(event, bit)
|
||||
return true // consumed
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ data class Settings(
|
||||
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
|
||||
*/
|
||||
val rumbleOnPhone: Boolean = false,
|
||||
|
||||
/**
|
||||
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
|
||||
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
|
||||
* directly (Linux hosts). ON by default — it engages only when such a controller is actually
|
||||
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
|
||||
* setup where the OS-level pad (lizard mode) is preferred.
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -151,6 +160,7 @@ class SettingsStore(context: Context) {
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -172,6 +182,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -208,6 +219,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_LOW_LATENCY = "low_latency_mode_v2"
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -426,6 +426,14 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
checked = s.rumbleOnPhone,
|
||||
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Steam Controller 2 passthrough",
|
||||
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " +
|
||||
"Bluetooth): it navigates these menus and streams as-is — Steam on the " +
|
||||
"host drives it like the physical pad (trackpads, gyro, haptics)",
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
@@ -18,10 +23,13 @@ import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -31,9 +39,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -43,6 +53,7 @@ import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -157,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}.onEach { it.setReferenceCounted(false) }
|
||||
}
|
||||
|
||||
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
|
||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||
var exitArming by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -199,6 +214,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
@@ -212,13 +230,69 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
|
||||
// actually present; the wire slot is claimed lazily on its first state report.
|
||||
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
|
||||
// claim the interfaces; it resumes in onDispose once the stream releases them.
|
||||
activity?.stopSc2MenuNav()
|
||||
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = sc2.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.startUsb(usbDev)
|
||||
usbDev != null -> {
|
||||
// One-time system dialog; capture engages on grant (Android remembers the
|
||||
// grant for as long as the device stays attached).
|
||||
val action = "io.unom.punktfunk.SC2_USB_PERMISSION"
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != action) return
|
||||
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (ok) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
|
||||
}
|
||||
}
|
||||
sc2UsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 0,
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
sc2.pairedBleAddress()?.let { addr ->
|
||||
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
|
||||
sc2.startBle(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.onHidRaw = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
activity?.startSc2MenuNav()
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
@@ -295,6 +369,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
||||
// broken. Purely visual; it sits above the video and below the gesture layer.
|
||||
if (exitArming) {
|
||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.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(
|
||||
@@ -321,6 +401,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
|
||||
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
|
||||
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
|
||||
* toggles its visibility.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitChordHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Hold to quit…",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -36,6 +36,16 @@ object Gamepad {
|
||||
const val BTN_X = 0x4000
|
||||
const val BTN_Y = 0x8000
|
||||
|
||||
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
|
||||
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1–P4), touchpad click, and the misc/QAM button.
|
||||
// Android's standard InputDevice path never produces these; the SC2 capture link does.
|
||||
const val BTN_PADDLE1 = 0x10000
|
||||
const val BTN_PADDLE2 = 0x20000
|
||||
const val BTN_PADDLE3 = 0x40000
|
||||
const val BTN_PADDLE4 = 0x80000
|
||||
const val BTN_TOUCHPAD = 0x100000
|
||||
const val BTN_MISC1 = 0x200000
|
||||
|
||||
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
|
||||
const val AXIS_LS_X = 0
|
||||
const val AXIS_LS_Y = 1
|
||||
@@ -54,6 +64,8 @@ object Gamepad {
|
||||
const val PREF_STEAMDECK = 6
|
||||
const val PREF_DUALSENSEEDGE = 7
|
||||
const val PREF_SWITCHPRO = 8
|
||||
const val PREF_STEAMCONTROLLER2 = 9
|
||||
const val PREF_STEAMCONTROLLER2_PUCK = 10
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
@@ -81,6 +93,12 @@ object Gamepad {
|
||||
private val PID_STEAMDECK = setOf(0x1205)
|
||||
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
|
||||
|
||||
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
|
||||
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
|
||||
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
|
||||
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
|
||||
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
@@ -107,6 +125,9 @@ object Gamepad {
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
|
||||
PREF_STEAMCONTROLLER2_PUCK
|
||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
|
||||
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ class GamepadFeedback(
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
|
||||
// A new host renews far below this, so it never actually holds this long there.
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
@@ -112,7 +113,8 @@ class GamepadFeedback(
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
val buf = ByteBuffer.allocateDirect(64)
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
@@ -331,10 +333,32 @@ class GamepadFeedback(
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
|
||||
// for verbatim replay on the physical controller; dropped when no link owns the pad.
|
||||
val kind = buf.get().toInt() and 0xFF
|
||||
val len = n - 3
|
||||
if (len > 0) {
|
||||
val data = ByteArray(len)
|
||||
buf.get(data)
|
||||
onHidRaw?.invoke(pad, kind, data)
|
||||
}
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
|
||||
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
|
||||
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
|
||||
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
|
||||
* physical controller.
|
||||
*/
|
||||
@Volatile
|
||||
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
|
||||
|
||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||
0b00000 -> 0
|
||||
|
||||
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
var onExitChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
|
||||
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
|
||||
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
|
||||
* quick press, and without an on-screen cue that reads as the shortcut being broken.
|
||||
*/
|
||||
var onExitArmed: ((armed: Boolean) -> Unit)? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
|
||||
private var pendingExit: Runnable? = null
|
||||
@@ -84,28 +92,37 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
|
||||
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
|
||||
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
|
||||
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
|
||||
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
|
||||
* chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
|
||||
* on the SDL/Apple clients. Any controller can leave.
|
||||
*/
|
||||
fun onButton(event: KeyEvent, bit: Int) {
|
||||
val slot = slotFor(event.device) ?: return
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
|
||||
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
// A chord button lifted before the hold elapsed → cancel, unless another pad still
|
||||
// holds the full chord.
|
||||
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
|
||||
disarmExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
if (pendingExit != null) return // already counting down
|
||||
val r = Runnable {
|
||||
pendingExit = null
|
||||
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
|
||||
// Fire only if the chord survived the full hold on some pad.
|
||||
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
|
||||
if (held.isNotEmpty()) {
|
||||
@@ -126,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
}
|
||||
pendingExit = r
|
||||
mainHandler.postDelayed(r, EXIT_HOLD_MS)
|
||||
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
|
||||
}
|
||||
|
||||
/** Cancel a pending exit-chord hold timer. */
|
||||
private fun disarmExit() {
|
||||
val wasArmed = pendingExit != null
|
||||
pendingExit?.let { mainHandler.removeCallbacks(it) }
|
||||
pendingExit = null
|
||||
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +173,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/**
|
||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||
* feedback poll threads.
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped) OR the slot is
|
||||
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
|
||||
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
|
||||
*/
|
||||
fun deviceForPad(pad: Int): InputDevice? {
|
||||
for ((deviceId, slot) in slots) {
|
||||
@@ -162,6 +184,50 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
|
||||
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
|
||||
* flush + Remove on [close], and full participation in the emergency exit chord.
|
||||
*/
|
||||
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
|
||||
// Live lookup instead of a captured reference: after [close] (or a router release) the
|
||||
// slot is gone from the table and every entry point below degrades to a safe no-op.
|
||||
private val slot get() = slots[syntheticId]
|
||||
|
||||
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
|
||||
fun button(bit: Int, down: Boolean) {
|
||||
slot?.let { slotButton(it, bit, down, send = true) }
|
||||
}
|
||||
|
||||
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
|
||||
fun axis(id: Int, value: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
|
||||
}
|
||||
|
||||
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
|
||||
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
|
||||
* indices are taken. Main thread (like the hot-plug callbacks).
|
||||
*/
|
||||
fun openExternal(pref: Int): ExternalPad? {
|
||||
val index = lowestFreeIndex() ?: return null
|
||||
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
|
||||
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
|
||||
val syntheticId = EXTERNAL_ID_BASE - index
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
@@ -250,7 +316,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
|
||||
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
|
||||
const val EXIT_HOLD_MS = 1500L
|
||||
/**
|
||||
* How long the exit chord must be held before the stream leaves — long enough that an
|
||||
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
|
||||
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
|
||||
*/
|
||||
const val EXIT_HOLD_MS = 1000L
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,16 @@ object NativeBridge {
|
||||
name: String,
|
||||
): String
|
||||
|
||||
/**
|
||||
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
|
||||
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
|
||||
* yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy",
|
||||
* "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes
|
||||
* yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say
|
||||
* WHY instead of the old catch-all that blamed the PIN for dead network paths.
|
||||
*/
|
||||
external fun nativeTakeLastError(): String
|
||||
|
||||
/**
|
||||
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
|
||||
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
|
||||
@@ -291,6 +301,14 @@ object NativeBridge {
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
|
||||
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
|
||||
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, …);
|
||||
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
@@ -302,10 +320,11 @@ object NativeBridge {
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
|
||||
* capacity >= 128) as `[pad][kind][fields…]` (leading pad = the wire pad index to route to):
|
||||
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…, raw as-is
|
||||
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
|
||||
* Returns the byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* BLE transport for a Steam Controller 2 paired directly with the device (no Puck). The standard
|
||||
* HID service (0x1812) is claimed by the OS (and would feed the pad through the ordinary input
|
||||
* stack in lizard-crippled form), so this talks Valve's vendor GATT service instead — the same
|
||||
* approach Steam itself uses on hosts without a dongle.
|
||||
*
|
||||
* GATT operations are serialized by a small state machine (connect → MTU → discover → subscribe
|
||||
* each notify char → lizard-off → ready); duplicate callbacks (the Android stack sometimes fires
|
||||
* `onMtuChanged` twice) are ignored. Notified state reports arrive with the report-id byte
|
||||
* stripped by the transport, so `0x45` (`ID_STATE_BLE`) is re-prepended for ≥40-byte payloads —
|
||||
* the wire then carries the same id-first framing as USB.
|
||||
*
|
||||
* Requires BLUETOOTH_CONNECT (the caller gates on it); connection priority is bumped to HIGH to
|
||||
* pull the connection interval from ~50 ms down to ~11 ms.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class Sc2BleLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
private enum class State { IDLE, CONNECTING, MTU_REQUESTED, DISCOVERING, SUBSCRIBING, READY }
|
||||
|
||||
private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var writeChar: BluetoothGattCharacteristic? = null
|
||||
private val pendingSubs = mutableListOf<BluetoothGattCharacteristic>()
|
||||
private var subsIndex = 0
|
||||
private val writeBusy = AtomicBoolean(false)
|
||||
private var lizardTicker: Thread? = null
|
||||
|
||||
@Volatile private var state = State.IDLE
|
||||
|
||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||
fun start(address: String): Boolean {
|
||||
val adapter = manager.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
state = State.CONNECTING
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host: output reports (rumble) ride WRITE_NO_RESPONSE so they
|
||||
* can't queue behind acks at the 25 Hz resend rate; feature reports (settings) use an acked
|
||||
* write. The report-id byte stays in the payload (the firmware's vendor-channel framing).
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (state != State.READY || data.isEmpty()) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
runCatching {
|
||||
ch.value = data
|
||||
ch.writeType = if (kind == 0) {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
} else {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
}
|
||||
g.writeCharacteristic(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLizardOff() {
|
||||
if (state != State.READY) return
|
||||
val g = gatt ?: return
|
||||
val ch = writeChar ?: return
|
||||
if (!writeBusy.compareAndSet(false, true)) return // previous acked write still in flight
|
||||
runCatching {
|
||||
ch.value = Sc2Device.DISABLE_LIZARD
|
||||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
if (!g.writeCharacteristic(ch)) writeBusy.set(false)
|
||||
}.onFailure { writeBusy.set(false) }
|
||||
}
|
||||
|
||||
/** Disconnect and stop the lizard ticker. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
lizardTicker?.interrupt()
|
||||
lizardTicker = null
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
// ~11 ms connection interval instead of the ~50 ms default — input latency.
|
||||
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||
if (state == State.CONNECTING) {
|
||||
state = State.MTU_REQUESTED
|
||||
if (!g.requestMtu(DESIRED_MTU)) {
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
val wasLive = state != State.IDLE
|
||||
runCatching { g.close() }
|
||||
gatt = null
|
||||
writeChar = null
|
||||
pendingSubs.clear()
|
||||
subsIndex = 0
|
||||
state = State.IDLE
|
||||
if (wasLive) onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (state != State.MTU_REQUESTED) return // fired twice on some stacks — act once
|
||||
state = State.DISCOVERING
|
||||
g.discoverServices()
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (state != State.DISCOVERING || status != BluetoothGatt.GATT_SUCCESS) return
|
||||
val valve = g.getService(VALVE_SERVICE) ?: run {
|
||||
Log.e(TAG, "Valve vendor service missing — not an SC2?")
|
||||
return
|
||||
}
|
||||
pendingSubs.clear()
|
||||
writeChar = null
|
||||
for (ch in valve.characteristics) {
|
||||
val short = shortUuid(ch.uuid) ?: continue
|
||||
val canNotify = ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
val canWrite = ch.properties and (
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE
|
||||
) != 0
|
||||
if (canNotify && short in NOTIFY_LOW..NOTIFY_HIGH) pendingSubs.add(ch)
|
||||
if (canWrite && short in WRITE_LOW..WRITE_HIGH && writeChar == null) writeChar = ch
|
||||
}
|
||||
subsIndex = 0
|
||||
state = State.SUBSCRIBING
|
||||
subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||
if (state == State.SUBSCRIBING) subscribeNext(g)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||
writeBusy.set(false)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||
val data = ch.value ?: return
|
||||
// BLE strips the report-id prefix; restore 0x45 on state-sized payloads so the raw
|
||||
// wire framing matches USB. Short payloads (battery/status) pass through as-is.
|
||||
if (data.size >= 40) {
|
||||
val framed = ByteArray(data.size + 1)
|
||||
framed[0] = Sc2Device.ID_STATE_BLE.toByte()
|
||||
System.arraycopy(data, 0, framed, 1, data.size)
|
||||
onReport(framed, framed.size)
|
||||
} else {
|
||||
onReport(data, data.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeNext(g: BluetoothGatt) {
|
||||
if (subsIndex >= pendingSubs.size) {
|
||||
state = State.READY
|
||||
Log.i(TAG, "SC2 BLE link up (${pendingSubs.size} notify chars)")
|
||||
sendLizardOff()
|
||||
// The firmware watchdog re-enables lizard mode; refresh on SDL's cadence until the
|
||||
// host's Steam takes over via the raw plane (its writes land through writeRaw too).
|
||||
lizardTicker = Thread({
|
||||
while (state == State.READY) {
|
||||
try {
|
||||
Thread.sleep(Sc2Device.LIZARD_REFRESH_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
sendLizardOff()
|
||||
}
|
||||
}, "pf-sc2-lizard").apply { isDaemon = true; start() }
|
||||
return
|
||||
}
|
||||
val ch = pendingSubs[subsIndex++]
|
||||
g.setCharacteristicNotification(ch, true)
|
||||
val cccd = ch.getDescriptor(CCCD) ?: return subscribeNext(g)
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (!g.writeDescriptor(cccd)) subscribeNext(g) // lose this one, try the rest
|
||||
}
|
||||
|
||||
/** The 32-bit short id of a Valve vendor UUID, or null for foreign UUIDs. */
|
||||
private fun shortUuid(uuid: UUID): Long? {
|
||||
val s = uuid.toString()
|
||||
if (!s.endsWith(VALVE_UUID_TAIL)) return null
|
||||
return s.substring(0, 8).toLongOrNull(16)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2BleLink"
|
||||
|
||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
const val NOTIFY_LOW = 0x100f6c75L
|
||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
const val WRITE_LOW = 0x100f6cb5L
|
||||
const val WRITE_HIGH = 0x100f6cbeL
|
||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
|
||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||
const val DESIRED_MTU = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
|
||||
* [Sc2BleLink]) and one of two consumers:
|
||||
*
|
||||
* **Stream mode** (`router != null`, owned by StreamScreen):
|
||||
* - **Raw plane (the point):** every input report is forwarded verbatim
|
||||
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
|
||||
* Steam Input drives like the physical controller.
|
||||
* - **Typed mirror:** buttons/sticks/triggers are ALSO diffed onto the ordinary per-transition
|
||||
* plane, so the emergency exit chord works, and a host that degraded the kind (no UHID → the
|
||||
* Xbox 360 pad) still gets a playable controller.
|
||||
* - **Raw return:** the host's hidraw writes (Steam's `0x80` rumble output reports, lizard/IMU
|
||||
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
|
||||
* the real controller's motors/firmware.
|
||||
*
|
||||
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
|
||||
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
|
||||
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
|
||||
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
|
||||
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
|
||||
*
|
||||
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
|
||||
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
|
||||
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
|
||||
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
|
||||
* and UI-mode consumers hop to the main thread themselves.
|
||||
*/
|
||||
class Sc2Capture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter? = null,
|
||||
) {
|
||||
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
|
||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||
private var activeLink: Int = LINK_NONE
|
||||
|
||||
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
|
||||
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
|
||||
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
|
||||
private var dongleLink = false
|
||||
|
||||
private var pad: GamepadRouter.ExternalPad? = null
|
||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
/** Puck connect arrives before its first state report (and therefore before a wire pad exists).
|
||||
* Preserve it so the native virtual Puck slot sees the same connect edge before state. */
|
||||
private val pendingWireless = ByteArray(2)
|
||||
private var pendingWirelessLen = 0
|
||||
|
||||
// Typed-mirror diff state (wire units).
|
||||
private val state = Sc2Device.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
|
||||
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
|
||||
private val seenIds = HashSet<Int>()
|
||||
|
||||
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
|
||||
private var uiHeld = HashSet<Int>()
|
||||
private var uiStickDir = 0
|
||||
|
||||
/**
|
||||
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
|
||||
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
|
||||
*/
|
||||
@Volatile
|
||||
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
|
||||
* connected" in the console-UI gate and the Controllers screen.
|
||||
*/
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
val isActive: Boolean get() = activeLink != LINK_NONE
|
||||
|
||||
/** First attached SC2/Puck USB device, for the permission flow. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* The first already-bonded BLE Steam Controller's address, or null. The caller checks
|
||||
* BLUETOOTH_CONNECT first (without it the bonded list reads as empty anyway).
|
||||
*/
|
||||
fun pairedBleAddress(): String? = ble.pairedControllers().firstOrNull()?.address
|
||||
|
||||
/** Start capturing [dev] over USB (permission already granted). */
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = usb.start(dev)
|
||||
if (ok) {
|
||||
activeLink = LINK_USB
|
||||
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
||||
onActiveChanged?.invoke(true)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Start capturing the bonded BLE controller at [address]. */
|
||||
fun startBle(address: String): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = ble.start(address)
|
||||
if (ok) {
|
||||
activeLink = LINK_BLE
|
||||
onActiveChanged?.invoke(true)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Replay a host raw write on the physical pad — wire to [GamepadFeedback.onHidRaw]. */
|
||||
fun onHidRaw(padIndex: Int, kind: Int, data: ByteArray) {
|
||||
if (padIndex != pad?.index) return // addressed to some other controller
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.writeRaw(kind, data)
|
||||
LINK_BLE -> ble.writeRaw(kind, data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
val wasActive = activeLink != LINK_NONE
|
||||
when (activeLink) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val id = report[0].toInt() and 0xFF
|
||||
if (seenIds.add(id)) Log.i(TAG, "SC2 report id=0x%02x seen (len=%d)".format(id, len))
|
||||
// Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
|
||||
// its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully
|
||||
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
|
||||
// marks the controller connected unconditionally and reconnects on any state report).
|
||||
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
||||
if (dongleLink) {
|
||||
when (report[1].toInt() and 0xFF) {
|
||||
Sc2Device.WIRELESS_CONNECT -> {
|
||||
pendingWireless[0] = report[0]
|
||||
pendingWireless[1] = report[1]
|
||||
pendingWirelessLen = 2
|
||||
}
|
||||
Sc2Device.WIRELESS_DISCONNECT -> {
|
||||
pendingWirelessLen = 0
|
||||
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!Sc2Device.parseState(report, len, state)) {
|
||||
// Battery/status and future report types still belong to the as-is stream.
|
||||
forwardRaw(report, len)
|
||||
return
|
||||
}
|
||||
if (router == null) {
|
||||
mirrorUi()
|
||||
return
|
||||
}
|
||||
val pref = if (dongleLink) {
|
||||
Gamepad.PREF_STEAMCONTROLLER2_PUCK
|
||||
} else {
|
||||
Gamepad.PREF_STEAMCONTROLLER2
|
||||
}
|
||||
val p = pad ?: router.openExternal(pref)?.also {
|
||||
pad = it
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)",
|
||||
)
|
||||
if (pendingWirelessLen > 0) {
|
||||
forwardRaw(pendingWireless, pendingWirelessLen)
|
||||
pendingWirelessLen = 0
|
||||
}
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
forwardRaw(report, len)
|
||||
mirrorTyped(p)
|
||||
}
|
||||
|
||||
private fun forwardRaw(report: ByteArray, len: Int) {
|
||||
val p = pad ?: return
|
||||
val n = len.coerceAtMost(rawBuf.capacity())
|
||||
rawBuf.clear()
|
||||
rawBuf.put(report, 0, n)
|
||||
p.hidReport(rawBuf, n)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
val wired = Sc2Device.wireButtons(state.buttons)
|
||||
var changed = wired xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, wired and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = wired
|
||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||
}
|
||||
|
||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||
if (lastAxis[id] == v) return
|
||||
lastAxis[id] = v
|
||||
p.axis(id, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
|
||||
* their Android keycodes (press AND release, so the focus system sees real holds); the left
|
||||
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
|
||||
* behavior MainActivity gives ordinary pads' sticks.
|
||||
*/
|
||||
private fun mirrorUi() {
|
||||
val sink = onUiKey ?: return
|
||||
val held = HashSet<Int>(8)
|
||||
var i = 0
|
||||
while (i < UI_KEY_MAP.size) {
|
||||
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
|
||||
i += 2
|
||||
}
|
||||
for (key in held) if (key !in uiHeld) sink(key, true)
|
||||
for (key in uiHeld) if (key !in held) sink(key, false)
|
||||
uiHeld = held
|
||||
// Left stick → a HELD D-pad direction (device convention: +y = up): pressed while
|
||||
// deflected, released on centre/direction change. The console UI's probe machinery
|
||||
// turns a held direction into its own auto-repeat, exactly like a physical D-pad; the
|
||||
// focus-hook path moves once per press edge either way.
|
||||
val dir = when {
|
||||
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
|
||||
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
|
||||
else -> 0
|
||||
}
|
||||
if (dir != uiStickDir) {
|
||||
// The D-pad bits share these keycodes; don't release a direction the physical
|
||||
// D-pad itself still holds (uiHeld tracks the button-sourced state).
|
||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||
if (dir != 0 && dir !in uiHeld) sink(dir, true)
|
||||
uiStickDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
|
||||
private fun releaseUiKeys() {
|
||||
val sink = onUiKey
|
||||
if (sink != null) {
|
||||
for (key in uiHeld) sink(key, false)
|
||||
if (uiStickDir != 0 && uiStickDir !in uiHeld) sink(uiStickDir, false)
|
||||
}
|
||||
uiHeld = HashSet()
|
||||
uiStickDir = 0
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
pad?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
pendingWirelessLen = 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2Capture"
|
||||
const val LINK_NONE = 0
|
||||
const val LINK_USB = 1
|
||||
const val LINK_BLE = 2
|
||||
|
||||
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
|
||||
const val STICK_NAV = 16384
|
||||
|
||||
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
|
||||
val UI_KEY_MAP = intArrayOf(
|
||||
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
|
||||
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
|
||||
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
|
||||
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
|
||||
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
|
||||
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
|
||||
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
|
||||
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Steam Controller 2 (2026, Valve "Ibex" / SDL "Triton") protocol constants + the light state
|
||||
* parser the CLIENT needs. The full report rides the wire verbatim (`nativeSendPadHidReport` →
|
||||
* the host's as-is virtual pad); this parser only extracts what the client itself consumes: the
|
||||
* button word for the typed mirror + exit chord, and sticks/triggers for the degrade path.
|
||||
*
|
||||
* Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
* (Valve-maintained), mirrored host-side in `punktfunk-host`'s `triton_proto.rs`.
|
||||
*/
|
||||
object Sc2Device {
|
||||
const val VID_VALVE = 0x28DE
|
||||
|
||||
/** Wired controller. */
|
||||
const val PID_WIRED = 0x1302
|
||||
|
||||
/** Direct BLE identity (transport handled by [Sc2BleLink], not USB). */
|
||||
const val PID_BLE = 0x1303
|
||||
|
||||
/** The wireless Puck dongles (Proteus / Nereid) — controller on USB interfaces 2..5. */
|
||||
const val PID_DONGLE_PROTEUS = 0x1304
|
||||
const val PID_DONGLE_NEREID = 0x1305
|
||||
|
||||
val USB_PIDS = setOf(PID_WIRED, PID_DONGLE_PROTEUS, PID_DONGLE_NEREID)
|
||||
|
||||
/** Dongle interface range that carries controllers (SDL: "interfaces 2..5, currently"). */
|
||||
val DONGLE_IFACES = 2..5
|
||||
|
||||
// Input report ids (`ETritonReportIDTypes`). State layouts share every offset the client
|
||||
// reads (seq/buttons/triggers/sticks); 0x47 only diverges from byte 18 (trackpad timestamp).
|
||||
const val ID_STATE = 0x42
|
||||
const val ID_BATTERY = 0x43
|
||||
const val ID_STATE_BLE = 0x45
|
||||
const val ID_WIRELESS_X = 0x46
|
||||
const val ID_STATE_TIMESTAMP = 0x47
|
||||
const val ID_WIRELESS = 0x79
|
||||
|
||||
/** Wireless status payload byte: controller connected/disconnected through the Puck. */
|
||||
const val WIRELESS_DISCONNECT = 1
|
||||
const val WIRELESS_CONNECT = 2
|
||||
|
||||
// Button bits in the state report's u32 (SDL `TritonButtons`).
|
||||
const val A = 0x00000001
|
||||
const val B = 0x00000002
|
||||
const val X = 0x00000004
|
||||
const val Y = 0x00000008
|
||||
const val QAM = 0x00000010
|
||||
const val R3 = 0x00000020
|
||||
const val VIEW = 0x00000040
|
||||
const val R4 = 0x00000080
|
||||
const val R5 = 0x00000100
|
||||
const val RB = 0x00000200
|
||||
const val DPAD_DOWN = 0x00000400
|
||||
const val DPAD_RIGHT = 0x00000800
|
||||
const val DPAD_LEFT = 0x00001000
|
||||
const val DPAD_UP = 0x00002000
|
||||
const val MENU = 0x00004000
|
||||
const val L3 = 0x00008000
|
||||
const val STEAM = 0x00010000
|
||||
const val L4 = 0x00020000
|
||||
const val L5 = 0x00040000
|
||||
const val LB = 0x00080000
|
||||
const val RPAD_CLICK = 0x00400000
|
||||
|
||||
/**
|
||||
* The feature report that turns lizard mode (built-in keyboard/mouse emulation) off:
|
||||
* `[report id 1][ID_SET_SETTINGS_VALUES 0x87][length 3][SETTING_LIZARD_MODE 9]
|
||||
* [LIZARD_MODE_OFF u16]`, zero-padded to the 64-byte feature size. The firmware watchdog
|
||||
* re-enables lizard mode after a few seconds of silence, so this is re-sent every
|
||||
* [LIZARD_REFRESH_MS] (SDL's cadence) — and the host's Steam sends its own through the raw
|
||||
* plane once it grabs the virtual pad, which lands here too.
|
||||
*/
|
||||
val DISABLE_LIZARD: ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01 // feature report id
|
||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
it[3] = 9 // SETTING_LIZARD_MODE
|
||||
// [4..6] = LIZARD_MODE_OFF (0) — already zero
|
||||
}
|
||||
|
||||
/**
|
||||
* Force firmware-calibrated signed i16 stick coordinates. Steam sends this during physical
|
||||
* controller initialization (`SETTING_ENABLE_RAW_JOYSTICK` = 0x2e, value 0); without it a
|
||||
* controller previously opened in raw mode reports ADC coordinates around 0..3200, which a
|
||||
* Triton consumer interprets as only a few percent of full travel.
|
||||
*/
|
||||
val NORMALIZE_JOYSTICKS: ByteArray = ByteArray(64).also {
|
||||
it[0] = 0x01 // feature report id
|
||||
it[1] = 0x87.toByte() // ID_SET_SETTINGS_VALUES
|
||||
it[2] = 3 // one ControllerSetting {u8 num, u16 value}
|
||||
it[3] = 0x2E // SETTING_ENABLE_RAW_JOYSTICK
|
||||
// [4..6] = disabled (0) — firmware emits calibrated signed i16 values
|
||||
}
|
||||
|
||||
const val LIZARD_REFRESH_MS = 3000L
|
||||
|
||||
/** Wire mapping: SC2 button bit → punktfunk `Gamepad.BTN_*`, the inverse of the host's
|
||||
* typed-fallback mapping (`triton_proto::from_gamepad`): paddles R4/L4/R5/L5 =
|
||||
* PADDLE1/2/3/4, QAM = MISC1, right-pad click = the touchpad wire bit. */
|
||||
private val WIRE_MAP = intArrayOf(
|
||||
A, Gamepad.BTN_A,
|
||||
B, Gamepad.BTN_B,
|
||||
X, Gamepad.BTN_X,
|
||||
Y, Gamepad.BTN_Y,
|
||||
LB, Gamepad.BTN_LB,
|
||||
RB, Gamepad.BTN_RB,
|
||||
VIEW, Gamepad.BTN_BACK,
|
||||
MENU, Gamepad.BTN_START,
|
||||
STEAM, Gamepad.BTN_GUIDE,
|
||||
L3, Gamepad.BTN_LS_CLICK,
|
||||
R3, Gamepad.BTN_RS_CLICK,
|
||||
DPAD_UP, Gamepad.BTN_DPAD_UP,
|
||||
DPAD_DOWN, Gamepad.BTN_DPAD_DOWN,
|
||||
DPAD_LEFT, Gamepad.BTN_DPAD_LEFT,
|
||||
DPAD_RIGHT, Gamepad.BTN_DPAD_RIGHT,
|
||||
QAM, Gamepad.BTN_MISC1,
|
||||
R4, Gamepad.BTN_PADDLE1,
|
||||
L4, Gamepad.BTN_PADDLE2,
|
||||
R5, Gamepad.BTN_PADDLE3,
|
||||
L5, Gamepad.BTN_PADDLE4,
|
||||
RPAD_CLICK, Gamepad.BTN_TOUCHPAD,
|
||||
)
|
||||
|
||||
/** Translate an SC2 button word into the wire `Gamepad.BTN_*` bitmask. */
|
||||
fun wireButtons(sc2: Int): Int {
|
||||
var out = 0
|
||||
var i = 0
|
||||
while (i < WIRE_MAP.size) {
|
||||
if (sc2 and WIRE_MAP[i] != 0) out = out or WIRE_MAP[i + 1]
|
||||
i += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The typed-mirror fields of one state report (buttons/sticks/triggers only). */
|
||||
class State {
|
||||
var buttons = 0 // SC2 bit layout
|
||||
var lsX = 0; var lsY = 0 // i16, +y = up (device convention = wire convention)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255 (device 0..32767 scaled down)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the client-consumed fields out of a state report (`0x42`/`0x45`/`0x47` — identical
|
||||
* offsets for everything read here) into [out]. Returns false for non-state / short reports.
|
||||
*/
|
||||
fun parseState(report: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 18) return false
|
||||
when (report[0].toInt() and 0xFF) {
|
||||
ID_STATE, ID_STATE_BLE, ID_STATE_TIMESTAMP -> {}
|
||||
else -> return false
|
||||
}
|
||||
fun i16(o: Int) = ((report[o + 1].toInt() shl 8) or (report[o].toInt() and 0xFF)).toShort().toInt()
|
||||
out.buttons = (report[2].toInt() and 0xFF) or
|
||||
((report[3].toInt() and 0xFF) shl 8) or
|
||||
((report[4].toInt() and 0xFF) shl 16) or
|
||||
((report[5].toInt() and 0xFF) shl 24)
|
||||
out.lt = (i16(6).coerceIn(0, 32767)) shr 7
|
||||
out.rt = (i16(8).coerceIn(0, 32767)) shr 7
|
||||
out.lsX = i16(10); out.lsY = i16(12)
|
||||
out.rsX = i16(14); out.rsY = i16(16)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
||||
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
||||
*
|
||||
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
||||
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||
* only interface 2 read silence while Android's input stack kept the others (the round-2
|
||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||
* streams state becomes the write target for rumble/settings.
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
||||
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
||||
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
||||
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
||||
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
||||
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
||||
* Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(TAG, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(TAG, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
// Refresh both required firmware modes. The raw-joystick setting is normally
|
||||
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
||||
// coordinates after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) configureInputMode(conn, target.iface.id)
|
||||
else live.forEach { configureInputMode(conn, it.iface.id) }
|
||||
lastLizard = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||
* report, id byte first, exactly as hidapi framed it host-side.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
||||
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
||||
}
|
||||
|
||||
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
||||
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
@@ -229,9 +229,11 @@ fn run_sync(
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
// to its receipt for the `decode` stage. Only fed while the HUD is visible.
|
||||
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
|
||||
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
|
||||
// for the `decode` stage. Fed while the HUD is visible OR the adaptive-bitrate controller wants
|
||||
// the decode signal (`measure_decode`) — the decoder-backlog bottleneck the network can't see.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
|
||||
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
|
||||
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
|
||||
@@ -272,40 +274,45 @@ fn run_sync(
|
||||
&p[..p.len().min(6)]
|
||||
);
|
||||
}
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
|
||||
// steady state skips the wall-clock read and the lock entirely. The receipt
|
||||
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
// Receipt stamp for the `decode` stage pairing, parked in `in_flight` (keyed by
|
||||
// the pts the codec echoes on its output buffer) whenever it's needed: the HUD
|
||||
// being visible, or the ABR decode signal (`measure_decode`). The HUD-only
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
// HUD stat, `received` point: host+network = client_now + (host−client) −
|
||||
// capture_pts.
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
// Phase-2 split: park this AU's capture→received sample, then match any
|
||||
// 0xCF host timings that have arrived — host = the host's own
|
||||
// capture→sent, network = our capture→received minus it (per-frame
|
||||
// tiling; saturating in case of clock jitter).
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front(); // 0xCF lost / old host — evict
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) =
|
||||
pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +352,8 @@ fn run_sync(
|
||||
};
|
||||
let (r, d) = drain(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&window,
|
||||
&mut applied_ds,
|
||||
wait,
|
||||
@@ -866,6 +875,9 @@ fn run_async(
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
|
||||
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
|
||||
let measure_decode = client.wants_decode_latency();
|
||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
@@ -886,7 +898,15 @@ fn run_async(
|
||||
std::thread::Builder::new()
|
||||
.name("pf-decode-feed".into())
|
||||
.spawn(move || {
|
||||
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
||||
feeder_loop(
|
||||
client,
|
||||
stats,
|
||||
measure_decode,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
shutdown,
|
||||
ev_tx,
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
@@ -976,6 +996,8 @@ fn run_async(
|
||||
let had_output = !ready.is_empty();
|
||||
present_ready(
|
||||
&codec,
|
||||
&client,
|
||||
measure_decode,
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
@@ -1052,6 +1074,7 @@ fn run_async(
|
||||
fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
measure_decode: bool,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -1067,13 +1090,11 @@ fn feeder_loop(
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if stats.enabled() {
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us =
|
||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
@@ -1083,19 +1104,27 @@ fn feeder_loop(
|
||||
g.pop_front(); // stale — codec never echoed it back
|
||||
}
|
||||
}
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
if stats.enabled() {
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
|
||||
if let Some(hostnet_us) = lat_us {
|
||||
pending_split.push_back((frame.pts_ns, hostnet_us));
|
||||
if pending_split.len() > PENDING_SPLIT_CAP {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1250,8 @@ fn feed_ready(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
|
||||
fn present_ready(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
ready: &mut Vec<OutputReady>,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &Mutex<VecDeque<(u64, i128)>>,
|
||||
@@ -1234,12 +1265,22 @@ fn present_ready(
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
if stats.enabled() {
|
||||
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
|
||||
// while visible) — both consume the receipt map, so enter for either.
|
||||
if stats.enabled() || measure_decode {
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
&mut g,
|
||||
clock_offset,
|
||||
o.pts_us,
|
||||
o.decoded_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
@@ -1460,6 +1501,8 @@ fn feed(
|
||||
#[allow(clippy::too_many_arguments)] // one call site; mirrors the async loop's present_ready
|
||||
fn drain(
|
||||
codec: &MediaCodec,
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
window: &NativeWindow,
|
||||
applied_ds: &mut Option<DataSpace>,
|
||||
first_wait: Duration,
|
||||
@@ -1489,11 +1532,20 @@ fn drain(
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() {
|
||||
let meta = if stats.enabled() || measure_decode {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||
Some((pts_us, decoded_ns))
|
||||
note_decoded_pts(
|
||||
client,
|
||||
measure_decode,
|
||||
stats,
|
||||
in_flight,
|
||||
clock_offset,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
);
|
||||
// The tracker's `display` stage is a HUD concern — park only when visible.
|
||||
stats.enabled().then_some((pts_us, decoded_ns))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1564,6 +1616,8 @@ fn drain(
|
||||
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
|
||||
/// stamp (async loop).
|
||||
fn note_decoded_pts(
|
||||
client: &NativeClient,
|
||||
measure_decode: bool,
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
@@ -1582,12 +1636,25 @@ fn note_decoded_pts(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
|
||||
// to < 1 µs — negligible against the ms-scale figures shown.
|
||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
// Adaptive bitrate: the `decode` stage (received→decoded, single-clock local) IS the decoder-
|
||||
// backlog signal — the only bottleneck the host-side network signals can't see (a fast LAN
|
||||
// feeding a slower mobile decoder). Report it whenever the controller is armed, regardless of
|
||||
// the HUD; `report_decode_us` is a cheap accumulate the pump windows.
|
||||
if measure_decode {
|
||||
if let Some(us) = decode_us {
|
||||
client.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
}
|
||||
// HUD histogram: only while the overlay is visible (a measure-only caller enters here for the
|
||||
// ABR report alone). `end-to-end` = capture→decoded (skew-corrected) tiles the `decode` stage.
|
||||
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time to
|
||||
// < 1 µs — negligible against the ms-scale figures shown.
|
||||
if stats.enabled() {
|
||||
let e2e_ns = decoded_ns + clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
|
||||
@@ -22,6 +22,7 @@ const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
const TAG_LED: u8 = 0x01;
|
||||
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
const TAG_HID_RAW: u8 = 0x05;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
@@ -143,6 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
// rumble already rides the universal 0xCA plane).
|
||||
return -1;
|
||||
}
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
|
||||
// the virtual pad; Kotlin replays it verbatim on the physical controller.
|
||||
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
|
||||
let n = 3 + data.len();
|
||||
if cap < n {
|
||||
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
|
||||
}
|
||||
out[0] = pad;
|
||||
out[1] = TAG_HID_RAW;
|
||||
out[2] = kind;
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -11,6 +11,43 @@ use std::time::Duration;
|
||||
|
||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||
|
||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||
/// catch-all "wrong PIN, or the host isn't armed" (which blamed the PIN for dead network paths
|
||||
/// — the moko0878-class support threads). The app runs one attempt at a time, so one slot
|
||||
/// suffices; a stale token is harmless (it is taken immediately after the failed call).
|
||||
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// Stable token for a failed pair/connect cause, matched by Kotlin (`ConnectErrors.kt`):
|
||||
/// a typed host rejection yields its `RejectReason::as_str()` token ("not-armed", "denied",
|
||||
/// "approval-timeout", …); transport-level causes map to "crypto" / "timeout" / "io" / "error".
|
||||
fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
use punktfunk_core::error::PunktfunkError as E;
|
||||
let token = match e {
|
||||
E::Rejected(r) => r.as_str(),
|
||||
E::Crypto => "crypto",
|
||||
E::Timeout => "timeout",
|
||||
E::Io(_) => "io",
|
||||
_ => "error",
|
||||
};
|
||||
*LAST_ERROR.lock().unwrap() = token.to_string();
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
@@ -185,6 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("nativeConnect to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -318,7 +356,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
|
||||
Ok(host_fp) => hex32(&host_fp),
|
||||
Err(e) => {
|
||||
// Crypto error == wrong PIN / MITM; anything else == transport/host reject.
|
||||
// The token lets Kotlin say WHICH (`nativeTakeLastError`).
|
||||
log::error!("nativePair to {host}:{port} failed: {e}");
|
||||
note_error(&e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||
|
||||
use jni::objects::JObject;
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
@@ -236,3 +237,43 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
) {
|
||||
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadHidReport(handle, pad, buf, len)` — one raw HID input report from a
|
||||
/// client-captured controller (the as-is Steam Controller 2 passthrough), forwarded verbatim on
|
||||
/// the rich-input plane (`RichInput::HidReport`, 0xCC). `buf` is a DIRECT ByteBuffer whose first
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
buf: JByteBuffer,
|
||||
len: jint,
|
||||
) {
|
||||
if handle == 0 || len <= 0 {
|
||||
return;
|
||||
}
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return,
|
||||
};
|
||||
let n = (len as usize).min(cap).min(HID_REPORT_MAX);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call;
|
||||
// `n` is bounded by both the buffer capacity and the fixed wire body.
|
||||
data[..n].copy_from_slice(unsafe { std::slice::from_raw_parts(ptr, n) });
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::HidReport {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
len: n as u8,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
<string>MicroGamepad</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
|
||||
@@ -42,6 +42,13 @@ let package = Package(
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
|
||||
.testTarget(
|
||||
name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"],
|
||||
resources: [
|
||||
// PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference
|
||||
// planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a
|
||||
// Vulkan box — see PyroWaveDecoderTests.swift).
|
||||
.copy("PyroWaveFixtures")
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -436,7 +436,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -478,7 +477,6 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
|
||||
@@ -46,6 +46,7 @@ struct ContentView: View {
|
||||
case "h264": return PunktfunkConnection.codecH264
|
||||
case "hevc": return PunktfunkConnection.codecHEVC
|
||||
case "av1": return PunktfunkConnection.codecAV1
|
||||
case "pyrowave": return PunktfunkConnection.codecPyroWave
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,18 @@ final class SessionModel: ObservableObject {
|
||||
// from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose.
|
||||
var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC
|
||||
if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 }
|
||||
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
|
||||
// advertises the bit and prefers it — the host never auto-selects it, and the
|
||||
// picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13;
|
||||
// every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
|
||||
// BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
|
||||
// session — HDR sessions stay HEVC/AV1 (plan §4.7).
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
|
||||
| PunktfunkConnection.videoCapHDR
|
||||
| PunktfunkConnection.videoCap444)
|
||||
}
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
@@ -284,10 +296,15 @@ final class SessionModel: ObservableObject {
|
||||
self.errorMessage = "\(host.displayName) is not paired yet. "
|
||||
+ "Pair with its PIN before streaming."
|
||||
}
|
||||
case .failure:
|
||||
case .failure(let error):
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
if let onUnreachable, !requestAccess {
|
||||
if case PunktfunkClientError.rejected(let rejection) = error {
|
||||
// The host answered and stated its reason (declined / approval timed
|
||||
// out / busy / versions differ) — show that, and never wake-retry a
|
||||
// host that is demonstrably awake.
|
||||
self.errorMessage = "\(host.displayName): \(rejection.userMessage)"
|
||||
} else if let onUnreachable, !requestAccess {
|
||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||
// own overlay explains what's happening.
|
||||
onUnreachable()
|
||||
|
||||
@@ -79,6 +79,13 @@ enum SettingsOptions {
|
||||
if AV1.hardwareDecodeSupported {
|
||||
options.insert(("AV1", "av1"), at: 2)
|
||||
}
|
||||
// PyroWave is the opt-in wired-LAN low-latency codec (100–400 Mbps all-intra wavelet,
|
||||
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
|
||||
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
|
||||
// host could never emit it.
|
||||
if MetalWaveletDecoder.supported {
|
||||
options.append(("PyroWave (wired LAN)", "pyrowave"))
|
||||
}
|
||||
return options
|
||||
}()
|
||||
|
||||
|
||||
@@ -212,14 +212,18 @@ struct PairSheet: View {
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
+ "and try again."
|
||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||
// The host answered and said why (not armed / rate-limited / armed for
|
||||
// another device) — show that instead of the guessing-game fallback.
|
||||
errorText = rejection.userMessage
|
||||
case .failure(is ClientIdentityStore.IdentityError):
|
||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
||||
+ "rate-limited to one per 2 seconds."
|
||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||
+ "isolation)?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ public func pair(
|
||||
switch rc {
|
||||
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||
default: throw PunktfunkClientError.status(rc)
|
||||
default:
|
||||
// A typed host rejection (pairing not armed / rate-limited / armed for another
|
||||
// device) carries its own reason — never report it as a bad PIN or dead network.
|
||||
if let rejection = HostRejection(status: rc) {
|
||||
throw PunktfunkClientError.rejected(rejection)
|
||||
}
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,68 @@ public enum PunktfunkClientError: Error {
|
||||
case wrongPIN
|
||||
case closed
|
||||
case status(Int32)
|
||||
/// The host deliberately turned the attempt away and said why (its typed QUIC
|
||||
/// application close) — distinct from `.connectFailed` (unreachable/timeout) so the UI
|
||||
/// can show the stated reason instead of blaming the network.
|
||||
case rejected(HostRejection)
|
||||
}
|
||||
|
||||
/// Why a host turned a connect/pair attempt away — decoded from the
|
||||
/// `PUNKTFUNK_STATUS_REJECTED_*` block. Lets the UI say "approve the request on the host"
|
||||
/// or "pairing isn't armed" instead of a generic "could not connect".
|
||||
public enum HostRejection: Sendable {
|
||||
case pairingNotArmed
|
||||
case pairingBoundToOtherDevice
|
||||
case pairingRateLimited
|
||||
case identityRequired
|
||||
case denied
|
||||
case approvalTimeout
|
||||
case superseded
|
||||
case wireVersionMismatch
|
||||
case busy
|
||||
|
||||
init?(status: Int32) {
|
||||
switch status {
|
||||
case PUNKTFUNK_STATUS_REJECTED_NOT_ARMED.rawValue: self = .pairingNotArmed
|
||||
case PUNKTFUNK_STATUS_REJECTED_BOUND_OTHER.rawValue: self = .pairingBoundToOtherDevice
|
||||
case PUNKTFUNK_STATUS_REJECTED_RATE_LIMITED.rawValue: self = .pairingRateLimited
|
||||
case PUNKTFUNK_STATUS_REJECTED_IDENTITY_REQUIRED.rawValue: self = .identityRequired
|
||||
case PUNKTFUNK_STATUS_REJECTED_DENIED.rawValue: self = .denied
|
||||
case PUNKTFUNK_STATUS_REJECTED_APPROVAL_TIMEOUT.rawValue: self = .approvalTimeout
|
||||
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
|
||||
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
|
||||
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing sentence — wording shared with the desktop clients.
|
||||
public var userMessage: String {
|
||||
switch self {
|
||||
case .pairingNotArmed:
|
||||
return "Pairing isn't armed on the host — arm it on the host's Pairing page, "
|
||||
+ "then try again."
|
||||
case .pairingBoundToOtherDevice:
|
||||
return "The host's pairing window is armed for a different device — arm it "
|
||||
+ "for this one."
|
||||
case .pairingRateLimited:
|
||||
return "Too many pairing attempts — wait a couple of seconds and try again."
|
||||
case .identityRequired:
|
||||
return "The host requires pairing — pair this device (PIN or request access) first."
|
||||
case .denied:
|
||||
return "The host declined this device's request."
|
||||
case .approvalTimeout:
|
||||
return "Nobody approved the request on the host in time — approve this device "
|
||||
+ "in the host's console or web UI, then request access again."
|
||||
case .superseded:
|
||||
return "A newer request from this device replaced this one — approve the "
|
||||
+ "latest request on the host."
|
||||
case .wireVersionMismatch:
|
||||
return "Client and host versions don't match — update both to the same release."
|
||||
case .busy:
|
||||
return "The host is busy with another session."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||
@@ -196,6 +258,11 @@ public final class PunktfunkConnection {
|
||||
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
|
||||
/// glyphs + positional layout on the host side.
|
||||
case switchPro = 8
|
||||
/// New Steam Controller (2026, `28DE:1302`), passed through as-is on Linux hosts (raw
|
||||
/// report mirroring; Steam Input is the consumer). Parity only on Apple — GameController
|
||||
/// never surfaces the raw Valve device, so the client can't capture one; exists so the
|
||||
/// resolved type round-trips and name parsing matches the host.
|
||||
case steamController2 = 9
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -208,6 +275,8 @@ public final class PunktfunkConnection {
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||
case "steamcontroller2", "steam-controller-2", "steamcon2", "sc2", "ibex":
|
||||
self = .steamController2
|
||||
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
|
||||
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||
self = .switchPro
|
||||
@@ -268,9 +337,15 @@ public final class PunktfunkConnection {
|
||||
public private(set) var resolvedAudioChannels: UInt8 = 2
|
||||
|
||||
/// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`):
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The
|
||||
/// resolved value honors the client's `preferredCodec` when the host could emit it.
|
||||
/// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this
|
||||
/// client opted in). Build the decoder from THIS. The resolved value honors the client's
|
||||
/// `preferredCodec` when the host could emit it.
|
||||
public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
|
||||
/// The session's negotiated wire shard payload (`Welcome.shard_payload`, bytes) — the
|
||||
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
|
||||
/// never need it.
|
||||
public private(set) var shardPayload: UInt32 = 1408
|
||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
@@ -312,6 +387,10 @@ public final class PunktfunkConnection {
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
// Why a failed connect failed (PunktfunkStatus): lets a typed host rejection
|
||||
// ("denied in the console", "approval timed out", "host busy") surface as
|
||||
// `.rejected` instead of the undifferentiated `.connectFailed`.
|
||||
var connectStatus: Int32 = 0
|
||||
// `videoCaps` advertises decode/present capability (PUNKTFUNK_VIDEO_CAP_10BIT | _HDR): the
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
@@ -322,24 +401,29 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex7(
|
||||
punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs)
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex7(
|
||||
return punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
nil, &observed, cert, key, timeoutMs)
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
||||
guard handle != nil else {
|
||||
if let rejection = HostRejection(status: connectStatus) {
|
||||
throw PunktfunkClientError.rejected(rejection)
|
||||
}
|
||||
throw PunktfunkClientError.connectFailed
|
||||
}
|
||||
hostFingerprint = Data(observed)
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||
@@ -374,6 +458,9 @@ public final class PunktfunkConnection {
|
||||
var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC
|
||||
_ = punktfunk_connection_codec(handle, &codec)
|
||||
resolvedCodec = codec
|
||||
var shard: UInt32 = 1408
|
||||
_ = punktfunk_connection_shard_payload(handle, &shard)
|
||||
shardPayload = shard
|
||||
}
|
||||
|
||||
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
|
||||
@@ -491,6 +578,30 @@ public final class PunktfunkConnection {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Report one decoded frame's decode-stage latency, in microseconds (the AU leaving `nextAU`
|
||||
/// through its VideoToolbox output). This feeds the Automatic bitrate controller's decode
|
||||
/// signal — the only one that sees this device's decoder — so the rate is capped at the real
|
||||
/// decode limit instead of climbing to the network link ceiling and choking the decoder. Cheap;
|
||||
/// silently dropped after close. Only worth calling when `wantsDecodeLatency()` is true.
|
||||
public func reportDecodeUs(_ us: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_report_decode_us(h, us)
|
||||
}
|
||||
|
||||
/// Whether `reportDecodeUs` is worth calling this session: true only when the adaptive-bitrate
|
||||
/// controller is armed (Automatic bitrate, non-PyroWave). Query once — constant for the session
|
||||
/// — and skip the per-frame decode measurement entirely when it's false. False after close.
|
||||
public func wantsDecodeLatency() -> Bool {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var out = false
|
||||
_ = punktfunk_connection_wants_decode_latency(h, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||
abiLock.lock()
|
||||
@@ -712,6 +823,15 @@ public final class PunktfunkConnection {
|
||||
public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264)
|
||||
public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC)
|
||||
public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1)
|
||||
/// PyroWave (opt-in wired-LAN wavelet codec, 8-bit SDR): the host only ever resolves it
|
||||
/// when the client both advertises the bit AND names it `preferredCodec` — never
|
||||
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
|
||||
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
|
||||
|
||||
/// `AccessUnit.flags` bit: the AU is shard-aligned self-delimiting chunks (the wire's
|
||||
/// `USER_FLAG_CHUNK_ALIGNED`, PyroWave datagram-aligned mode §4.4) — walk it
|
||||
/// window-by-window at `shardPayload`. (The C `#define` doesn't import into Swift.)
|
||||
public static let userFlagChunkAligned: UInt32 = 64
|
||||
|
||||
/// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR
|
||||
/// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order,
|
||||
|
||||
@@ -27,8 +27,10 @@ public enum DefaultsKey {
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
public static let audioChannels = "punktfunk.audioChannels"
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference —
|
||||
/// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`.
|
||||
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or
|
||||
/// `"pyrowave"` (the opt-in wired-LAN wavelet codec — picking it advertises AND prefers it,
|
||||
/// and forces the session SDR). A soft preference — the host emits it when it can, else
|
||||
/// falls back. Drives the decoder via `Welcome.codec`.
|
||||
public static let codec = "punktfunk.codec"
|
||||
public static let micEnabled = "punktfunk.micEnabled"
|
||||
public static let speakerUID = "punktfunk.speakerUID"
|
||||
|
||||
@@ -543,19 +543,24 @@ public enum AV1 {
|
||||
|
||||
extension VideoCodec {
|
||||
/// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence
|
||||
/// header, the NAL codecs on in-band parameter sets — one call site in each pump.
|
||||
/// header, the NAL codecs on in-band parameter sets — one call site in each pump. PyroWave
|
||||
/// has no CoreMedia representation at all (its pump feeds the Metal wavelet decoder raw).
|
||||
public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? {
|
||||
self == .av1
|
||||
? AV1.formatDescription(fromKeyframe: au)
|
||||
: AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.formatDescription(fromKeyframe: au)
|
||||
case .pyrowave: return nil
|
||||
default: return AnnexB.formatDescription(fromIDR: au, codec: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`).
|
||||
public func sampleBuffer(
|
||||
au: AccessUnit, format: CMVideoFormatDescription
|
||||
) -> CMSampleBuffer? {
|
||||
self == .av1
|
||||
? AV1.sampleBuffer(au: au, format: format)
|
||||
: AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||
switch self {
|
||||
case .av1: return AV1.sampleBuffer(au: au, format: format)
|
||||
case .pyrowave: return nil
|
||||
default: return AnnexB.sampleBuffer(au: au, format: format, codec: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,18 @@ public enum VideoCodec: Equatable {
|
||||
case h264
|
||||
case hevc
|
||||
case av1
|
||||
/// PyroWave wavelet (opt-in wired-LAN low-latency codec): not a NAL/OBU codec and not
|
||||
/// VideoToolbox-decoded at all — the Metal wavelet decoder consumes the raw AUs
|
||||
/// (Stage2Pipeline's PyroWave pump). Only ever resolved when this client both advertised
|
||||
/// and preferred it.
|
||||
case pyrowave
|
||||
|
||||
/// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC).
|
||||
public init(wire: UInt8) {
|
||||
switch wire {
|
||||
case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264
|
||||
case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1
|
||||
case 0x08: self = .pyrowave // PUNKTFUNK_CODEC_PYROWAVE
|
||||
default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec
|
||||
}
|
||||
}
|
||||
@@ -147,8 +153,8 @@ public enum AnnexB {
|
||||
sets = [vps, sps, pps]
|
||||
case .h264:
|
||||
sets = [sps, pps]
|
||||
case .av1:
|
||||
return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here
|
||||
case .av1, .pyrowave:
|
||||
return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here
|
||||
}
|
||||
|
||||
var format: CMVideoFormatDescription?
|
||||
@@ -184,8 +190,8 @@ public enum AnnexB {
|
||||
parameterSetSizes: sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &format)
|
||||
case .av1:
|
||||
break // unreachable — the .av1 arm above already returned
|
||||
case .av1, .pyrowave:
|
||||
break // unreachable — the arm above already returned
|
||||
}
|
||||
}
|
||||
return status == noErr ? format : nil
|
||||
|
||||
@@ -149,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]],
|
||||
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||
}
|
||||
|
||||
// PyroWave planar SDR: three separate R8 planes (Y full-res, Cb/Cr half-res 4:2:0) from the
|
||||
// Metal wavelet decoder — the Metal twin of pf-presenter's planar_csc.frag. Same bicubic luma
|
||||
// and left-cosited chroma correction as the biplanar path (chromaUV self-disables at 4:4:4).
|
||||
fragment float4 pf_frag_planar(VOut in [[stage_in]],
|
||||
texture2d<float> lumaTex [[texture(0)]],
|
||||
texture2d<float> cbTex [[texture(1)]],
|
||||
texture2d<float> crTex [[texture(2)]],
|
||||
constant CscUniform& csc [[buffer(0)]]) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
float lumaY = lumaTex.sample(s, in.uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
|
||||
#endif
|
||||
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
|
||||
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
|
||||
float3 rgb = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||
return float4(rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
||||
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
||||
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||
@@ -215,8 +237,16 @@ public final class MetalVideoPresenter {
|
||||
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
||||
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
/// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`.
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
|
||||
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means
|
||||
/// decode, CSC and present share textures with zero interop, and one queue means Metal's
|
||||
/// hazard tracking orders a ring-slot rewrite after the render still sampling it.
|
||||
var metalDevice: MTLDevice { device }
|
||||
var metalQueue: MTLCommandQueue { queue }
|
||||
|
||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||
@@ -258,6 +288,7 @@ public final class MetalVideoPresenter {
|
||||
let pipelineSDR: MTLRenderPipelineState
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
let pipelinePlanar: MTLRenderPipelineState
|
||||
do {
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
@@ -292,6 +323,11 @@ public final class MetalVideoPresenter {
|
||||
#else
|
||||
pipelineHDRToneMap = nil
|
||||
#endif
|
||||
let planar = MTLRenderPipelineDescriptor()
|
||||
planar.vertexFunction = vtx
|
||||
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
|
||||
planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
|
||||
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -331,12 +367,14 @@ public final class MetalVideoPresenter {
|
||||
|
||||
return MetalVideoPresenter(
|
||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
|
||||
textureCache: textureCache, layer: layer)
|
||||
}
|
||||
|
||||
private init(
|
||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||
pipelinePlanar: MTLRenderPipelineState,
|
||||
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||
) {
|
||||
self.device = device
|
||||
@@ -344,6 +382,7 @@ public final class MetalVideoPresenter {
|
||||
self.pipelineSDR = pipelineSDR
|
||||
self.pipelineHDR = pipelineHDR
|
||||
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||
self.pipelinePlanar = pipelinePlanar
|
||||
self.textureCache = textureCache
|
||||
self.layer = layer
|
||||
}
|
||||
@@ -514,6 +553,67 @@ public final class MetalVideoPresenter {
|
||||
pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache)
|
||||
else { return false }
|
||||
|
||||
#if os(tvOS)
|
||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||
let pipeline = hdrActive ? hdrPipeline : pipelineSDR
|
||||
#else
|
||||
let pipeline = hdrActive ? pipelineHDR : pipelineSDR
|
||||
#endif
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||
keepAlive: [luma, chroma, pixelBuffer]
|
||||
) { encoder in
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one PyroWave planar frame (three R8 planes off the Metal wavelet decoder) and
|
||||
/// present it. RENDER THREAD, same contract as `render` — PyroWave is 8-bit SDR, so the
|
||||
/// layer always takes the plain SDR config, and the CSC rows arrive precomputed from the
|
||||
/// stream's own sequence-header signaling (no CVPixelBuffer to inspect).
|
||||
@discardableResult
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
stagingLock.unlock()
|
||||
configure(hdr: false)
|
||||
var csc = planes.csc
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||
) { encoder in
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
private func encodePresent(
|
||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||
// SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the
|
||||
// main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass:
|
||||
@@ -522,8 +622,6 @@ public final class MetalVideoPresenter {
|
||||
// Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT
|
||||
// track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change
|
||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||
let decodedSize = CGSize(
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||
@@ -542,17 +640,8 @@ public final class MetalVideoPresenter {
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
#if os(tvOS)
|
||||
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
|
||||
#else
|
||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||
#endif
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
if let onPresented {
|
||||
@@ -580,9 +669,8 @@ public final class MetalVideoPresenter {
|
||||
} else {
|
||||
commandBuffer.present(drawable)
|
||||
}
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes
|
||||
// sampling — releasing them at scope exit could free the backing mid-read.
|
||||
commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) }
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
// PyroWave native Metal decoder — the Apple twin of pf-client-core's Vulkan decoder
|
||||
// (crates/pf-client-core/src/video_pyrowave.rs), reimplemented on the presenter's own MTLDevice
|
||||
// so decode + CSC + present share one device with zero interop (design/pyrowave-codec-plan.md
|
||||
// §4.7). No upstream C/C++ ships in the app: the bitstream parse below reimplements
|
||||
// pyrowave_decoder.cpp's push_packet/decode_packet walk, and the two compute kernels
|
||||
// (MetalWaveletShaders.swift) are hand-ported from the vendored GLSL. The §4.2 upstream pin
|
||||
// covers this hand-port: a vendored bump means re-diffing two decode shaders and the two 8-byte
|
||||
// header structs, and it is already a protocol-version event.
|
||||
//
|
||||
// Wire shape (all fixed by the host encoder, punktfunk-host encode/linux/pyrowave.rs):
|
||||
// • One AU = one frame = a self-delimiting stream of packets. Each packet is one 32x32
|
||||
// coefficient block for one (component, level, band), self-sized by its 8-byte
|
||||
// BitstreamHeader; a per-frame START_OF_FRAME sequence header carries dims + total block
|
||||
// count + the VUI bits (chroma 4:2:0, BT.709/BT.2020, limited/full).
|
||||
// • With `USER_FLAG_CHUNK_ALIGNED` (Phase 4) the AU is a whole number of `shard_payload`-sized
|
||||
// windows, each 4-byte-prefixed (used-len u16 LE + kind u16 LE): kind 0 = whole packets,
|
||||
// 1/2/3 = FRAG chain for a packet bigger than one window. A missing shard of a partial frame
|
||||
// arrives as an all-zero window (used = 0) → skipped, its blocks reconstruct as zeros
|
||||
// (localized blur, the Phase-4 design intent). The reassembler enables partial delivery
|
||||
// core-side automatically for PyroWave sessions.
|
||||
// • Decode acceptance mirrors upstream decode_is_ready(allow_partial=true): a frame with no
|
||||
// SOF or with no more than half its blocks is dropped rather than decoded to garbage.
|
||||
//
|
||||
// GPU structure per frame (mirroring pyrowave_decoder.cpp's barriers): one concurrent compute
|
||||
// encoder with all ~42 dequant dispatches (each writes a distinct band layer — no intra-stage
|
||||
// hazards), then one concurrent encoder per iDWT level (5) — encoder boundaries provide the
|
||||
// write→sampled-read synchronization the Vulkan version expresses as pipeline barriers. The
|
||||
// output is a ring of 4 plane sets (Y full-res + Cb/Cr half-res R8Unorm); ring depth plus
|
||||
// same-queue hazard tracking keeps a set alive while the presenter still samples it (the same
|
||||
// scheme as the Vulkan client's ring).
|
||||
|
||||
#if canImport(Metal)
|
||||
import Foundation
|
||||
import Metal
|
||||
import os
|
||||
|
||||
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
|
||||
|
||||
/// The per-(component, level, band) 32x32-block table — the exact Swift port of
|
||||
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 4→0,
|
||||
/// component 0→2 skipping level-0 chroma in 4:2:0, band (level==4 ? 0 : 1)→3) DEFINES the
|
||||
/// global `block_index` space the wire packets address, so it must match the encoder exactly.
|
||||
struct WaveletLayout {
|
||||
static let decompositionLevels = 5
|
||||
static let alignment = 32
|
||||
static let minimumImageSize = 128
|
||||
|
||||
let width: Int
|
||||
let height: Int
|
||||
let alignedWidth: Int
|
||||
let alignedHeight: Int
|
||||
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
|
||||
/// band not coded (level-0 chroma in 4:2:0).
|
||||
let blockMeta: [[[(offset: Int, stride: Int)]]]
|
||||
let blockCount32: Int
|
||||
|
||||
/// Band-image extent at `level` — mip `level` of the (aligned/2)-sized coefficient image.
|
||||
/// Exact halving: the aligned dims are 32-aligned, so /2 is 16-aligned and survives 4 shifts.
|
||||
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
|
||||
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
|
||||
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
let align = { (v: Int) in
|
||||
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
|
||||
}
|
||||
alignedWidth = align(width)
|
||||
alignedHeight = align(height)
|
||||
|
||||
var meta = [[[(offset: Int, stride: Int)]]](
|
||||
repeating: [[(offset: Int, stride: Int)]](
|
||||
repeating: [(offset: Int, stride: Int)](repeating: (-1, 0), count: 4),
|
||||
count: Self.decompositionLevels),
|
||||
count: 3)
|
||||
var count32 = 0
|
||||
let aw = alignedWidth
|
||||
let ah = alignedHeight
|
||||
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
|
||||
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let levelW = (aw / 2) >> level
|
||||
let levelH = (ah / 2) >> level
|
||||
let blocksX8 = (levelW + 7) / 8
|
||||
let blocksY8 = (levelH + 7) / 8
|
||||
let blocksX32 = (levelW + 31) / 32
|
||||
meta[component][level][band] = (count32, blocksX32)
|
||||
// accumulate_block_mapping's 32x32 count.
|
||||
count32 += ((blocksX8 + 3) / 4) * ((blocksY8 + 3) / 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
blockMeta = meta
|
||||
blockCount32 = count32
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed frame, CPU side: the per-block payload offset table + the flat payload words the
|
||||
/// dequant kernel consumes (packet words INCLUDING each 8-byte header, as upstream uploads
|
||||
/// them), plus the sequence header's facts.
|
||||
struct ParsedWaveletFrame {
|
||||
var layout: WaveletLayout
|
||||
/// Per 32x32 block: u32 word offset into `payload`, or UInt32.max = block missing.
|
||||
var offsets: [UInt32]
|
||||
var payload: [UInt32]
|
||||
var totalBlocks: Int
|
||||
var decodedBlocks: Int
|
||||
/// VUI bits from the sequence header (BitstreamSequenceHeader).
|
||||
var bt2020: Bool
|
||||
var fullRange: Bool
|
||||
|
||||
/// The frame's Y′CbCr→RGB signal for the presenter's planar CSC. PyroWave today is always
|
||||
/// BT.709 limited (the host's fixed contract), but the sequence header signals it, so honor
|
||||
/// what it says.
|
||||
var cscSignal: CscRows.Signal {
|
||||
CscRows.Signal(matrix: bt2020 ? 9 : 1, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
|
||||
enum WaveletBitstream {
|
||||
/// Window kinds of the chunk-aligned framing (host WIN_* constants).
|
||||
private static let winPacked: UInt16 = 0
|
||||
private static let winFragFirst: UInt16 = 1
|
||||
private static let winFragCont: UInt16 = 2
|
||||
private static let winFragLast: UInt16 = 3
|
||||
|
||||
/// Parse one AU into the dequant kernel's inputs. `windowSize` > 0 with `chunkAligned`
|
||||
/// walks the Phase-4 shard-window framing first; otherwise the AU is one packet stream.
|
||||
/// nil = drop the frame (malformed, no SOF, or not enough blocks survived loss to be worth
|
||||
/// decoding — upstream's `decoded_blocks > total/2` partial rule).
|
||||
static func parse(au: Data, chunkAligned: Bool, windowSize: Int) -> ParsedWaveletFrame? {
|
||||
var state = ParseState()
|
||||
let ok = au.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
||||
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return false
|
||||
}
|
||||
let count = raw.count
|
||||
if chunkAligned, windowSize >= 8 {
|
||||
// Whole windows only; a trailing partial window would be a framing bug.
|
||||
guard count % windowSize == 0 else { return false }
|
||||
var frag: [UInt8] = []
|
||||
var fragLive = false
|
||||
var pos = 0
|
||||
while pos < count {
|
||||
let win = UnsafeBufferPointer(start: base + pos, count: windowSize)
|
||||
pos += windowSize
|
||||
let used = Int(win[0]) | (Int(win[1]) << 8)
|
||||
let kind = UInt16(win[2]) | (UInt16(win[3]) << 8)
|
||||
// A zeroed (missing) shard or an overrun drops the window AND breaks any
|
||||
// fragment chain riding across it (mirrors video_pyrowave.rs push_window).
|
||||
guard used > 0, 4 + used <= windowSize else {
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
continue
|
||||
}
|
||||
let body = UnsafeBufferPointer(start: win.baseAddress! + 4, count: used)
|
||||
switch kind {
|
||||
case winPacked:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
guard state.pushPackets(body) else { return false }
|
||||
case winFragFirst:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
frag.append(contentsOf: body)
|
||||
fragLive = true
|
||||
case winFragCont:
|
||||
if fragLive { frag.append(contentsOf: body) }
|
||||
case winFragLast:
|
||||
if fragLive {
|
||||
frag.append(contentsOf: body)
|
||||
let ok = frag.withUnsafeBufferPointer { state.pushPackets($0) }
|
||||
guard ok else { return false }
|
||||
}
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
default:
|
||||
frag.removeAll(keepingCapacity: true)
|
||||
fragLive = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return state.pushPackets(UnsafeBufferPointer(start: base, count: count))
|
||||
}
|
||||
guard ok, let frame = state.finish() else { return nil }
|
||||
// Upstream decode_is_ready(allow_partial=true): with no SOF the frame is undecodable;
|
||||
// at half the blocks or fewer it is presumed garbage.
|
||||
guard frame.totalBlocks > 0, frame.decodedBlocks > frame.totalBlocks / 2 else {
|
||||
return nil
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
/// Streaming packet-walk state (pyrowave_decoder.cpp push_packet + decode_packet). The
|
||||
/// SOF sequence header arrives first in every host AU, which fixes the dims → layout →
|
||||
/// offset-table size before any coefficient packet lands; a coefficient packet before the
|
||||
/// SOF (its window was lost) is skipped — its block just stays missing.
|
||||
private struct ParseState {
|
||||
var layout: WaveletLayout?
|
||||
var offsets: [UInt32] = []
|
||||
var payload: [UInt32] = []
|
||||
var totalBlocks = 0
|
||||
var decodedBlocks = 0
|
||||
var bt2020 = false
|
||||
var fullRange = false
|
||||
var sawSOF = false
|
||||
|
||||
mutating func pushPackets(_ buf: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
guard let base = buf.baseAddress else { return true }
|
||||
var pos = 0
|
||||
let count = buf.count
|
||||
while count - pos >= 8 {
|
||||
let word0 = loadWord(base, pos)
|
||||
let word1 = loadWord(base, pos + 4)
|
||||
let extended = (word0 >> 31) & 1
|
||||
if extended != 0 {
|
||||
// BitstreamSequenceHeader: w-1[0:14] h-1[14:28] seq[28:31] ext[31];
|
||||
// total[0:24] code[24:26] chroma[26] prim[27] trc[28] mtx[29] range[30]
|
||||
// siting[31].
|
||||
let code = (word1 >> 24) & 0x3
|
||||
guard code == 0 else { return false } // only START_OF_FRAME is defined
|
||||
let chromaRes = (word1 >> 26) & 1
|
||||
guard chromaRes == 0 else { return false } // host contract: 4:2:0
|
||||
let w = Int(word0 & 0x3fff) + 1
|
||||
let h = Int((word0 >> 14) & 0x3fff) + 1
|
||||
guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
|
||||
if sawSOF {
|
||||
// One frame, one geometry — a second SOF must agree.
|
||||
guard layout?.width == w, layout?.height == h else { return false }
|
||||
} else {
|
||||
sawSOF = true
|
||||
let l = WaveletLayout(width: w, height: h)
|
||||
layout = l
|
||||
offsets = [UInt32](repeating: .max, count: l.blockCount32)
|
||||
payload.reserveCapacity(64 * 1024 / 4)
|
||||
totalBlocks = Int(word1 & 0xff_ffff)
|
||||
bt2020 = (word1 >> 29) & 1 != 0
|
||||
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
|
||||
}
|
||||
pos += 8
|
||||
continue
|
||||
}
|
||||
// BitstreamHeader: ballot[0:16] payload_words[16:28] seq[28:31] ext[31];
|
||||
// quant_code[0:8] block_index[8:32]. payload_words counts u32s INCLUDING the
|
||||
// 8-byte header.
|
||||
let payloadWords = Int((word0 >> 16) & 0xfff)
|
||||
guard payloadWords >= 2, pos + payloadWords * 4 <= count else { return false }
|
||||
let blockIndex = Int(word1 >> 8)
|
||||
if let layout, blockIndex < layout.blockCount32 {
|
||||
// First write wins (duplicate packets are ignored, like upstream).
|
||||
if offsets[blockIndex] == .max {
|
||||
offsets[blockIndex] = UInt32(payload.count)
|
||||
decodedBlocks += 1
|
||||
payload.reserveCapacity(payload.count + payloadWords)
|
||||
for w in 0..<payloadWords {
|
||||
payload.append(loadWord(base, pos + w * 4))
|
||||
}
|
||||
}
|
||||
} else if layout != nil {
|
||||
return false // out-of-bounds block index — corrupt stream
|
||||
}
|
||||
// No layout yet (SOF lost): skip the packet, the block stays missing.
|
||||
pos += payloadWords * 4
|
||||
}
|
||||
// In the windowed framing, `used` delimits exactly; dense AUs must also consume
|
||||
// fully (upstream errors on trailing bytes).
|
||||
return pos == count
|
||||
}
|
||||
|
||||
private func loadWord(_ base: UnsafePointer<UInt8>, _ offset: Int) -> UInt32 {
|
||||
UInt32(base[offset])
|
||||
| (UInt32(base[offset + 1]) << 8)
|
||||
| (UInt32(base[offset + 2]) << 16)
|
||||
| (UInt32(base[offset + 3]) << 24)
|
||||
}
|
||||
|
||||
func finish() -> ParsedWaveletFrame? {
|
||||
guard let layout else { return nil }
|
||||
return ParsedWaveletFrame(
|
||||
layout: layout, offsets: offsets, payload: payload,
|
||||
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
|
||||
bt2020: bt2020, fullRange: fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded frame's output planes, handed to the presenter's planar render path. The
|
||||
/// textures belong to the decoder's ring — ring depth (4) plus same-queue hazard tracking keep
|
||||
/// them valid while referenced. Public because it rides inside `ReadyImage`.
|
||||
public struct WaveletPlanes: @unchecked Sendable {
|
||||
public let y: MTLTexture
|
||||
public let cb: MTLTexture
|
||||
public let cr: MTLTexture
|
||||
public let csc: CscUniform
|
||||
public var width: Int { y.width }
|
||||
public var height: Int { y.height }
|
||||
}
|
||||
|
||||
public final class MetalWaveletDecoder {
|
||||
/// Matches the Vulkan client's ring: deep enough that a slot is never rewritten while the
|
||||
/// presenter still samples it in practice; same-queue hazard tracking is the hard backstop.
|
||||
private static let ringDepth = 4
|
||||
|
||||
/// Device-capability gate for advertisement (SessionModel) and the settings picker: the
|
||||
/// dequant kernel needs simdgroup prefix sums with its 16 header lanes inside one
|
||||
/// simdgroup, so compile the real kernels once and check the pipeline facts. Apple6 (A13)
|
||||
/// and every Mac2 device pass the family check; the compile probe is authoritative.
|
||||
public static let supported: Bool = {
|
||||
guard let device = MTLCreateSystemDefaultDevice() else { return false }
|
||||
guard device.supportsFamily(.apple6) || device.supportsFamily(.mac2) else { return false }
|
||||
do {
|
||||
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||
guard let dequant = lib.makeFunction(name: "wavelet_dequant") else { return false }
|
||||
let p = try device.makeComputePipelineState(function: dequant)
|
||||
var shift = false
|
||||
let fc = MTLFunctionConstantValues()
|
||||
fc.setConstantValue(&shift, type: .bool, index: 0)
|
||||
_ = try lib.makeFunction(name: "idwt", constantValues: fc)
|
||||
return p.threadExecutionWidth >= 16 && p.maxTotalThreadsPerThreadgroup >= 128
|
||||
} catch {
|
||||
waveletLog.info("pyrowave probe: kernels rejected (\(error, privacy: .public))")
|
||||
return false
|
||||
}
|
||||
}()
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
private let dequantPipeline: MTLComputePipelineState
|
||||
private let idwtPipeline: MTLComputePipelineState
|
||||
private let idwtShiftPipeline: MTLComputePipelineState
|
||||
private let mirrorSampler: MTLSamplerState
|
||||
|
||||
// Size-dependent state, rebuilt when the SOF dims change (this is also the mid-stream
|
||||
// Reconfigure/resize path — the wavelet decoder is fixed-size per geometry).
|
||||
private var layout: WaveletLayout?
|
||||
/// coefficients[component][level]: 4-slice R16Float (levels 0–1) / R32Float (levels 2–4)
|
||||
/// texture2d_array — the band images (precision-1 split, see MetalWaveletShaders).
|
||||
private var coefficients: [[MTLTexture]] = []
|
||||
/// llViews[component][level]: slice-0 (LL band) 2D write view of `coefficients` — the iDWT
|
||||
/// output target chaining level L+1 into level L.
|
||||
private var llViews: [[MTLTexture]] = []
|
||||
|
||||
private struct Slot {
|
||||
var y: MTLTexture
|
||||
var cb: MTLTexture
|
||||
var cr: MTLTexture
|
||||
var offsets: MTLBuffer
|
||||
var payload: MTLBuffer
|
||||
}
|
||||
|
||||
private var slots: [Slot] = []
|
||||
private var nextSlot = 0
|
||||
|
||||
/// The current geometry (from the last SOF that built the resources) — the pump reports
|
||||
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
|
||||
var decodedSize: (width: Int, height: Int)? {
|
||||
layout.map { ($0.width, $0.height) }
|
||||
}
|
||||
|
||||
/// The pump thread owns `decode`; everything mutable is confined to it.
|
||||
init?(device: MTLDevice, queue: MTLCommandQueue) {
|
||||
self.device = device
|
||||
self.queue = queue
|
||||
do {
|
||||
let lib = try device.makeLibrary(source: waveletShaderSource, options: nil)
|
||||
guard let dequantFn = lib.makeFunction(name: "wavelet_dequant") else { return nil }
|
||||
dequantPipeline = try device.makeComputePipelineState(function: dequantFn)
|
||||
var shift = false
|
||||
let fcOff = MTLFunctionConstantValues()
|
||||
fcOff.setConstantValue(&shift, type: .bool, index: 0)
|
||||
idwtPipeline = try device.makeComputePipelineState(
|
||||
function: try lib.makeFunction(name: "idwt", constantValues: fcOff))
|
||||
shift = true
|
||||
let fcOn = MTLFunctionConstantValues()
|
||||
fcOn.setConstantValue(&shift, type: .bool, index: 0)
|
||||
idwtShiftPipeline = try device.makeComputePipelineState(
|
||||
function: try lib.makeFunction(name: "idwt", constantValues: fcOn))
|
||||
} catch {
|
||||
waveletLog.error("pyrowave: pipeline build failed (\(error, privacy: .public))")
|
||||
return nil
|
||||
}
|
||||
guard dequantPipeline.threadExecutionWidth >= 16,
|
||||
dequantPipeline.maxTotalThreadsPerThreadgroup >= 128
|
||||
else { return nil }
|
||||
// Upstream's mirror_repeat_sampler: mirrored repeat, NEAREST everything, normalized
|
||||
// coords — the idwt gather footprint + coordinate nudge depend on exactly this.
|
||||
let samp = MTLSamplerDescriptor()
|
||||
samp.sAddressMode = .mirrorRepeat
|
||||
samp.tAddressMode = .mirrorRepeat
|
||||
samp.minFilter = .nearest
|
||||
samp.magFilter = .nearest
|
||||
samp.mipFilter = .notMipmapped
|
||||
samp.normalizedCoordinates = true
|
||||
guard let sampler = device.makeSamplerState(descriptor: samp) else { return nil }
|
||||
mirrorSampler = sampler
|
||||
}
|
||||
|
||||
/// Decode one AU. Synchronous CPU parse + async GPU decode: returns false when the frame
|
||||
/// was dropped (malformed / SOF lost / not enough blocks); on true, `completion` fires on a
|
||||
/// Metal callback thread once the planes are decoded (nil = the GPU pass errored).
|
||||
/// PUMP THREAD only.
|
||||
func decode(
|
||||
au: Data, chunkAligned: Bool, windowSize: Int,
|
||||
completion: @escaping @Sendable (WaveletPlanes?) -> Void
|
||||
) -> Bool {
|
||||
guard
|
||||
let frame = WaveletBitstream.parse(
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize)
|
||||
else { return false }
|
||||
|
||||
if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|
||||
guard rebuild(layout: frame.layout) else { return false }
|
||||
}
|
||||
guard let layout, !slots.isEmpty else { return false }
|
||||
|
||||
var slot = slots[nextSlot]
|
||||
// Grow the payload buffer to the frame (+16-byte zeroed guard: the kernel's 64-bit
|
||||
// sign-window load and eager plane-byte prefetch may read past the payload end —
|
||||
// upstream pads its Vulkan buffer for exactly this).
|
||||
let payloadBytes = frame.payload.count * 4
|
||||
if slot.payload.length < payloadBytes + 16 {
|
||||
guard
|
||||
let grown = device.makeBuffer(
|
||||
length: max(64 * 1024, (payloadBytes + 16) * 2), options: .storageModeShared)
|
||||
else { return false }
|
||||
slot.payload = grown
|
||||
slots[nextSlot] = slot
|
||||
}
|
||||
frame.offsets.withUnsafeBytes { src in
|
||||
slot.offsets.contents().copyMemory(
|
||||
from: src.baseAddress!, byteCount: min(src.count, slot.offsets.length))
|
||||
}
|
||||
frame.payload.withUnsafeBytes { src in
|
||||
slot.payload.contents().copyMemory(from: src.baseAddress!, byteCount: src.count)
|
||||
}
|
||||
memset(slot.payload.contents() + payloadBytes, 0, 16)
|
||||
|
||||
guard let cmd = queue.makeCommandBuffer() else { return false }
|
||||
|
||||
// Stage 1: dequant — every (component, level, band) block grid in one concurrent
|
||||
// encoder (each dispatch writes its own band layer; no intra-stage hazards, exactly
|
||||
// like the barrier-free Vulkan dispatch loop).
|
||||
guard let dequant = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||
return false
|
||||
}
|
||||
dequant.label = "pyrowave dequant"
|
||||
dequant.setComputePipelineState(dequantPipeline)
|
||||
dequant.setBuffer(slot.offsets, offset: 0, index: 0)
|
||||
dequant.setBuffer(slot.payload, offset: 0, index: 1)
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue } // 4:2:0
|
||||
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
|
||||
let meta = layout.blockMeta[component][level][band]
|
||||
let w = layout.levelWidth(level)
|
||||
let h = layout.levelHeight(level)
|
||||
var regs = DequantRegisters(
|
||||
resolution: SIMD2(Int32(w), Int32(h)),
|
||||
outputLayer: Int32(band),
|
||||
blockOffset32x32: Int32(meta.offset),
|
||||
blockStride32x32: Int32(meta.stride))
|
||||
dequant.setTexture(coefficients[component][level], index: 0)
|
||||
dequant.setBytes(
|
||||
®s, length: MemoryLayout<DequantRegisters>.stride, index: 2)
|
||||
dequant.dispatchThreadgroups(
|
||||
MTLSize(width: (w + 31) / 32, height: (h + 31) / 32, depth: 1),
|
||||
threadsPerThreadgroup: MTLSize(width: 128, height: 1, depth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
dequant.endEncoding()
|
||||
|
||||
// Stage 2: iDWT, coarsest level in — one encoder per level; the encoder boundary is
|
||||
// the write→sampled-read barrier chaining each level's LL into the next.
|
||||
for inputLevel in stride(from: WaveletLayout.decompositionLevels - 1, through: 0, by: -1) {
|
||||
guard let idwt = cmd.makeComputeCommandEncoder(dispatchType: .concurrent) else {
|
||||
return false
|
||||
}
|
||||
idwt.label = "pyrowave idwt L\(inputLevel)"
|
||||
idwt.setSamplerState(mirrorSampler, index: 0)
|
||||
// Resolution rides TRANSPOSED (the kernel transposes on load and store).
|
||||
let rx = layout.levelHeight(inputLevel)
|
||||
let ry = layout.levelWidth(inputLevel)
|
||||
var regs = IdwtRegisters(
|
||||
resolution: SIMD2(Int32(rx), Int32(ry)),
|
||||
invResolution: SIMD2(1.0 / Float(rx), 1.0 / Float(ry)))
|
||||
idwt.setBytes(®s, length: MemoryLayout<IdwtRegisters>.stride, index: 0)
|
||||
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
|
||||
let group = MTLSize(width: 64, height: 1, depth: 1)
|
||||
if inputLevel == 0 {
|
||||
// 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(coefficients[0][0], index: 0)
|
||||
idwt.setTexture(slot.y, index: 1)
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
} else {
|
||||
for component in 0..<3 {
|
||||
idwt.setTexture(coefficients[component][inputLevel], index: 0)
|
||||
if component != 0 && inputLevel == 1 {
|
||||
// 4:2:0 chroma emits its final half-res plane one level early.
|
||||
idwt.setComputePipelineState(idwtShiftPipeline)
|
||||
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
|
||||
} else {
|
||||
idwt.setComputePipelineState(idwtPipeline)
|
||||
idwt.setTexture(llViews[component][inputLevel - 1], index: 1)
|
||||
}
|
||||
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
|
||||
}
|
||||
}
|
||||
idwt.endEncoding()
|
||||
}
|
||||
|
||||
let planes = WaveletPlanes(
|
||||
y: slot.y, cb: slot.cb, cr: slot.cr,
|
||||
csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
|
||||
cmd.addCompletedHandler { buffer in
|
||||
completion(buffer.error == nil ? planes : nil)
|
||||
}
|
||||
cmd.commit()
|
||||
nextSlot = (nextSlot + 1) % Self.ringDepth
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
|
||||
/// resize path: a Reconfigure shows up here as new SOF dims.
|
||||
private func rebuild(layout newLayout: WaveletLayout) -> Bool {
|
||||
waveletLog.info(
|
||||
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
|
||||
var coeff: [[MTLTexture]] = []
|
||||
var lls: [[MTLTexture]] = []
|
||||
for component in 0..<3 {
|
||||
var perLevel: [MTLTexture] = []
|
||||
var perLevelLL: [MTLTexture] = []
|
||||
for level in 0..<WaveletLayout.decompositionLevels {
|
||||
let desc = MTLTextureDescriptor()
|
||||
desc.textureType = .type2DArray
|
||||
desc.arrayLength = 4
|
||||
// Upstream precision 1: fp16 storage for the two finest levels, fp32 for the
|
||||
// coarse levels whose values feed every later reconstruction step.
|
||||
desc.pixelFormat = level < 2 ? .r16Float : .r32Float
|
||||
desc.width = newLayout.levelWidth(level)
|
||||
desc.height = newLayout.levelHeight(level)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
guard let tex = device.makeTexture(descriptor: desc) else { return false }
|
||||
tex.label = "pyrowave coeff c\(component) L\(level)"
|
||||
guard
|
||||
let ll = tex.makeTextureView(
|
||||
pixelFormat: desc.pixelFormat, textureType: .type2D,
|
||||
levels: 0..<1, slices: 0..<1)
|
||||
else { return false }
|
||||
ll.label = "pyrowave LL c\(component) L\(level)"
|
||||
perLevel.append(tex)
|
||||
perLevelLL.append(ll)
|
||||
}
|
||||
coeff.append(perLevel)
|
||||
lls.append(perLevelLL)
|
||||
}
|
||||
|
||||
var newSlots: [Slot] = []
|
||||
for i in 0..<Self.ringDepth {
|
||||
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.shaderRead, .shaderWrite]
|
||||
desc.storageMode = .private
|
||||
let t = self.device.makeTexture(descriptor: desc)
|
||||
t?.label = name
|
||||
return t
|
||||
}
|
||||
guard
|
||||
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
|
||||
let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
|
||||
let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
|
||||
let offsets = device.makeBuffer(
|
||||
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
|
||||
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
|
||||
else { return false }
|
||||
newSlots.append(Slot(y: y, cb: cb, cr: cr, offsets: offsets, payload: payload))
|
||||
}
|
||||
|
||||
coefficients = coeff
|
||||
llViews = lls
|
||||
slots = newSlots
|
||||
nextSlot = 0
|
||||
layout = newLayout
|
||||
return true
|
||||
}
|
||||
|
||||
// MSL-side layouts (MetalWaveletShaders.swift) — keep in lockstep.
|
||||
private struct DequantRegisters {
|
||||
var resolution: SIMD2<Int32>
|
||||
var outputLayer: Int32
|
||||
var blockOffset32x32: Int32
|
||||
var blockStride32x32: Int32
|
||||
}
|
||||
|
||||
private struct IdwtRegisters {
|
||||
var resolution: SIMD2<Int32>
|
||||
var invResolution: SIMD2<Float>
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,551 @@
|
||||
// PyroWave decode compute kernels — the Metal port of the vendored Vulkan shaders
|
||||
// (crates/pyrowave-sys/vendor/pyrowave/shaders/wavelet_dequant.comp + idwt.comp, upstream pin
|
||||
// 509e4f88, MIT © 2025 Hans-Kristian Arntzen). Runtime-compiled Swift strings per client
|
||||
// convention (no metallib build step — see GamepadChrome.swift's rationale); these are the
|
||||
// client's first compute pipelines.
|
||||
//
|
||||
// Port notes (design/pyrowave-codec-plan.md §4.7):
|
||||
// • Only the STORAGE_MODE 0 path exists: MSL device pointers replace the 8/16-bit-storage SSBO
|
||||
// aliases; the texel-buffer (mode 1) and linear-image (mode 2) fallbacks are non-Apple IHV
|
||||
// workarounds and are dropped, as is the fragment-iDWT path (Mali/Adreno only).
|
||||
// • Subgroup ops map 1:1: subgroupInclusiveAdd → simd_prefix_inclusive_sum, and the fixed
|
||||
// 32-wide Apple simdgroups take the GLSL's `SubgroupSize <= 32` scan branch; the shuffle-up
|
||||
// and LDS fallbacks for exotic wave sizes are dead code here. The dequant kernel needs the
|
||||
// 16 header lanes inside ONE simdgroup — MetalWaveletDecoder's probe enforces
|
||||
// threadExecutionWidth >= 16.
|
||||
// • Precision matches upstream's desktop default (PYROWAVE_PRECISION=1): float arithmetic,
|
||||
// half2 threadgroup storage; the coefficient textures are R16Float for DWT levels 0–1 and
|
||||
// R32Float for levels 2–4 (the low-res levels feed long reconstruction chains — upstream
|
||||
// keeps them fp32 for exactly that reason).
|
||||
// • The gather + mirrored-repeat addressing in idwt is the precision-sensitive spot (upstream
|
||||
// fought a Mali compiler bug there); the golden-frame PSNR fixtures are the guard.
|
||||
|
||||
import Foundation
|
||||
|
||||
let waveletShaderSource = """
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared helpers (dwt_swizzle.h / constants.h / dwt_quant_scale.h)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static inline int2 unswizzle8x8(uint index)
|
||||
{
|
||||
uint y = extract_bits(index, 0, 1);
|
||||
uint x = extract_bits(index, 1, 2);
|
||||
y |= extract_bits(index, 3, 2) << 1;
|
||||
x |= extract_bits(index, 5, 1) << 2;
|
||||
return int2(int(x), int(y));
|
||||
}
|
||||
|
||||
// GLSL bitfieldExtract(x, 0, n) where n may be 0; MSL extract_bits(bits=0) is not guaranteed
|
||||
// to return 0, so mask explicitly.
|
||||
static inline uint mask_lo(uint x, int n)
|
||||
{
|
||||
return (n <= 0) ? 0u : (x & (0xffffffffu >> (32 - n)));
|
||||
}
|
||||
|
||||
// pyrowave_common.hpp decode_quant: custom FP formulation, MaxScaleExp = 4.
|
||||
static inline float decode_quant(uint quant_code)
|
||||
{
|
||||
int e = 4 - int(quant_code >> 3);
|
||||
int m = int(quant_code) & 0x7;
|
||||
return (1.0f / (8.0f * 1024.0f * 1024.0f)) * float((8 + m) * (1 << (20 + e)));
|
||||
}
|
||||
|
||||
// dwt_quant_scale.h: per-8x8 quant scale, min 0.25, max ~2.2.
|
||||
static inline float decode_quant_scale(uint code)
|
||||
{
|
||||
return float(code) / 8.0f + 0.25f;
|
||||
}
|
||||
|
||||
// constants.h
|
||||
constant int QUANT_SCALE_OFFSET = 20;
|
||||
constant int QUANT_SCALE_BITS = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// wavelet_dequant — one 128-thread threadgroup decodes one 32x32 coefficient block
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
struct DequantRegisters {
|
||||
int2 resolution;
|
||||
int output_layer;
|
||||
int block_offset_32x32;
|
||||
int block_stride_32x32;
|
||||
};
|
||||
|
||||
struct DecodedPair { float4 col0; float4 col1; }; // GLSL mat2x4: m[j][i] -> colJ[i]
|
||||
|
||||
// Bit-plane magnitude decode for one thread's 4x2 coefficient group (decode_payload in the
|
||||
// GLSL). `code_word` is the 8x8 block's 16-bit control word (2 bits of extra planes per 4x2
|
||||
// group), `q_bits` the base plane count, `offset` the block's plane-payload start byte,
|
||||
// `block_index` this thread's group (0..7). Nonzero magnitudes get the +0.5 deadzone
|
||||
// reconstruction bias.
|
||||
static DecodedPair decode_payload(const device uchar *payload_u8,
|
||||
uint code_word, uint q_bits, uint offset, uint block_index)
|
||||
{
|
||||
DecodedPair m;
|
||||
m.col0 = float4(0.0f);
|
||||
m.col1 = float4(0.0f);
|
||||
if (code_word == 0)
|
||||
return m;
|
||||
|
||||
int bit_offset = 2 * int(block_index);
|
||||
|
||||
uint lsbs = code_word & 0x5555u;
|
||||
uint msbs = code_word & 0xaaaau;
|
||||
uint msbs_shift = msbs >> 1;
|
||||
msbs |= msbs_shift;
|
||||
|
||||
uint byte_offset =
|
||||
popcount(mask_lo(lsbs, bit_offset)) +
|
||||
popcount(mask_lo(msbs, bit_offset)) +
|
||||
q_bits * block_index + offset;
|
||||
|
||||
uint payload = uint(payload_u8[byte_offset]);
|
||||
|
||||
uint local_control_word = extract_bits(code_word, uint(bit_offset), 2);
|
||||
int decoded_abs[8] = {0, 0, 0, 0, 0, 0, 0, 0};
|
||||
int plane_iterations = int(q_bits + local_control_word);
|
||||
|
||||
for (int q = plane_iterations - 1; q >= 0; q--)
|
||||
{
|
||||
for (int b = 0; b < 8; b++)
|
||||
{
|
||||
int decoded = int(extract_bits(payload, uint(b), 1));
|
||||
decoded_abs[b] = insert_bits(decoded_abs[b], decoded, uint(q), 1);
|
||||
}
|
||||
byte_offset++;
|
||||
payload = uint(payload_u8[byte_offset]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
{
|
||||
float v = float(decoded_abs[i * 2 + j]);
|
||||
if (v != 0.0f)
|
||||
v += 0.5f;
|
||||
if (j == 0) m.col0[i] = v; else m.col1[i] = v;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
kernel void wavelet_dequant(
|
||||
texture2d_array<float, access::write> uDequantImg [[texture(0)]],
|
||||
const device uint *payload_offsets [[buffer(0)]],
|
||||
const device uint *payload_u32 [[buffer(1)]],
|
||||
constant DequantRegisters ®isters [[buffer(2)]],
|
||||
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||
uint local_index [[thread_index_in_threadgroup]],
|
||||
uint simd_lane [[thread_index_in_simdgroup]],
|
||||
uint simd_group [[simdgroup_index_in_threadgroup]],
|
||||
uint simd_size [[threads_per_simdgroup]])
|
||||
{
|
||||
// STORAGE_MODE 0's three aliased SSBO views over one buffer, as typed pointers.
|
||||
const device ushort *payload_u16 = reinterpret_cast<const device ushort *>(payload_u32);
|
||||
const device uchar *payload_u8 = reinterpret_cast<const device uchar *>(payload_u32);
|
||||
|
||||
threadgroup uint shared_sign_offset;
|
||||
threadgroup uint shared_plane_byte_offsets[16];
|
||||
threadgroup uint shared_sign_scan[128 / 4];
|
||||
|
||||
int block_index_32x32 = int(uint(registers.block_offset_32x32) +
|
||||
wg_id.y * uint(registers.block_stride_32x32) +
|
||||
wg_id.x);
|
||||
|
||||
uint block_local_index = extract_bits(local_index, 0, 3);
|
||||
uint block_x = extract_bits(local_index, 3, 2);
|
||||
uint block_y = extract_bits(local_index, 5, 2);
|
||||
uint linear_block = block_y * 4 + block_x;
|
||||
|
||||
// Each thread individually decodes 8 values (a 4x2 group of its 8x8 block).
|
||||
int2 local_coord = unswizzle8x8(block_local_index << 3);
|
||||
|
||||
int2 coord = int2(wg_id.xy) * 32;
|
||||
coord += 8 * int2(int(block_x), int(block_y));
|
||||
coord += local_coord;
|
||||
|
||||
uint offset_u32 = payload_offsets[block_index_32x32];
|
||||
|
||||
// Missing / lost block: zero coefficients (this is how a partial frame's holes decode).
|
||||
if (offset_u32 == ~0u)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
uDequantImg.write(float4(0.0f), uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||
return;
|
||||
}
|
||||
|
||||
uint ballot = payload_u32[offset_u32] & 0xffffu;
|
||||
uint q_code = payload_u32[offset_u32 + 1] & 0xffu;
|
||||
|
||||
// Threads 0..15 (one per 8x8 block, all inside simdgroup 0) prefix-scan the per-block
|
||||
// plane-payload byte costs into shared_plane_byte_offsets, and lane 15 records where the
|
||||
// sign bitstream starts.
|
||||
if (local_index < 16)
|
||||
{
|
||||
uint control_word = 0;
|
||||
uint q_bits = 0;
|
||||
|
||||
if (extract_bits(ballot, local_index, 1) != 0)
|
||||
{
|
||||
uint local_code_offset = popcount(mask_lo(ballot, int(local_index)));
|
||||
control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||
q_bits = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]) & 0xfu;
|
||||
}
|
||||
|
||||
uint lsbs = control_word & 0x5555u;
|
||||
uint msbs = control_word & 0xaaaau;
|
||||
uint msbs_shift = msbs >> 1;
|
||||
msbs |= msbs_shift;
|
||||
uint byte_cost = popcount(lsbs) + popcount(msbs) + q_bits * 8;
|
||||
|
||||
uint byte_scan = offset_u32 * 4 + 8 + 3 * popcount(ballot) + simd_prefix_inclusive_sum(byte_cost);
|
||||
if (local_index == 15)
|
||||
shared_sign_offset = 8 * byte_scan;
|
||||
shared_plane_byte_offsets[local_index] = byte_scan - byte_cost;
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
DecodedPair v;
|
||||
int significant_count;
|
||||
|
||||
if (extract_bits(ballot, linear_block, 1) != 0)
|
||||
{
|
||||
uint local_code_offset = popcount(mask_lo(ballot, int(linear_block)));
|
||||
|
||||
uint control_word = uint(payload_u16[offset_u32 * 2 + 4 + local_code_offset]);
|
||||
uint control_word2 = uint(payload_u8[offset_u32 * 4 + 8 + popcount(ballot) * 2 + local_code_offset]);
|
||||
|
||||
v = decode_payload(payload_u8, control_word, control_word2 & 0xfu,
|
||||
shared_plane_byte_offsets[linear_block], block_local_index);
|
||||
|
||||
significant_count = 0;
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
significant_count += int(((j == 0) ? v.col0[i] : v.col1[i]) != 0.0f);
|
||||
|
||||
float q = decode_quant(q_code);
|
||||
float inv_scale = q * decode_quant_scale(extract_bits(control_word2, uint(QUANT_SCALE_OFFSET - 16), uint(QUANT_SCALE_BITS)));
|
||||
|
||||
v.col0 *= inv_scale;
|
||||
v.col1 *= inv_scale;
|
||||
}
|
||||
else
|
||||
{
|
||||
v.col0 = float4(0.0f);
|
||||
v.col1 = float4(0.0f);
|
||||
significant_count = 0;
|
||||
}
|
||||
|
||||
// Cross-threadgroup scan of significant-coefficient counts → each thread's first sign-bit
|
||||
// position. Apple simdgroups are >= 16 wide, so this is the GLSL's `SubgroupSize <= 32`
|
||||
// branch; the shuffle/LDS fallbacks are unnecessary.
|
||||
int significant_scan = int(simd_prefix_inclusive_sum(uint(significant_count)));
|
||||
if (simd_lane == simd_size - 1)
|
||||
shared_sign_scan[simd_group] = uint(significant_scan);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
uint num_simdgroups = (128 + simd_size - 1) / simd_size;
|
||||
if (local_index < num_simdgroups)
|
||||
shared_sign_scan[local_index] = simd_prefix_inclusive_sum(shared_sign_scan[local_index]);
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
uint sign_offset = shared_sign_offset + uint(significant_scan - significant_count);
|
||||
if (simd_group != 0)
|
||||
sign_offset += shared_sign_scan[simd_group - 1];
|
||||
|
||||
// Load 64 bits of sign stream and bit-align (may read one word past the payload — the
|
||||
// buffer carries a 16-byte zeroed guard tail for exactly this).
|
||||
uint sign_word = payload_u32[sign_offset / 32 + 0];
|
||||
uint sign_word_upper = payload_u32[sign_offset / 32 + 1];
|
||||
|
||||
uint masked_sign_offset = sign_offset & 31u;
|
||||
if (masked_sign_offset != 0)
|
||||
{
|
||||
sign_word >>= masked_sign_offset;
|
||||
sign_word |= sign_word_upper << (32 - masked_sign_offset);
|
||||
}
|
||||
|
||||
int sign_counter = 0;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
for (int j = 0; j < 2; j++)
|
||||
{
|
||||
float val = (j == 0) ? v.col0[i] : v.col1[i];
|
||||
if (val != 0.0f)
|
||||
{
|
||||
val *= 1.0f - 2.0f * float(extract_bits(sign_word, uint(sign_counter), 1));
|
||||
sign_counter++;
|
||||
if (j == 0) v.col0[i] = val; else v.col1[i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < 2; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
uDequantImg.write(float4((j == 0) ? v.col0[i] : v.col1[i]),
|
||||
uint2(coord + int2(i, j)), uint(registers.output_layer));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// idwt — inverse CDF 9/7; one 64-thread threadgroup reconstructs one 32x32 output tile from the
|
||||
// four half-res band layers (LL/HL/LH/HH), with a 4-sample mirror apron. The caller passes the
|
||||
// band-image resolution TRANSPOSED (the kernel transposes on load and store, so one kernel does
|
||||
// both the horizontal and vertical passes).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
constant bool DCShift [[function_constant(0)]];
|
||||
|
||||
struct IdwtRegisters {
|
||||
int2 resolution;
|
||||
float2 inv_resolution;
|
||||
};
|
||||
|
||||
constant int APRON = 4;
|
||||
constant int APRON_HALF = APRON / 2;
|
||||
constant int BLOCK_SIZE = 32;
|
||||
constant int BLOCK_SIZE_HALF = BLOCK_SIZE >> 1;
|
||||
|
||||
// CDF 9/7 lifting constants (dwt_common.h).
|
||||
constant float ALPHA = -1.586134342059924f;
|
||||
constant float BETA = -0.052980118572961f;
|
||||
constant float GAMMA = 0.882911075530934f;
|
||||
constant float DELTA = 0.443506852043971f;
|
||||
constant float K = 1.230174104914001f;
|
||||
constant float inv_K = 1.0f / 1.230174104914001f;
|
||||
|
||||
constant int SHARED_ROWS = (BLOCK_SIZE + 2 * APRON) / 2; // 20
|
||||
constant int SHARED_COLS = (BLOCK_SIZE + 2 * APRON) + 1; // 41 (+1 avoids bank conflicts)
|
||||
|
||||
static inline float2 load_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x)
|
||||
{
|
||||
return float2(blk[y][x]);
|
||||
}
|
||||
|
||||
static inline void store_shared(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], int y, int x, float2 v)
|
||||
{
|
||||
blk[y][x] = half2(v);
|
||||
}
|
||||
|
||||
// Even/odd-phase coordinate nudge so mirrored-repeat gather reproduces JPEG2000 whole-sample
|
||||
// mirroring at the image borders, then transpose (uv.yx) on load.
|
||||
static inline float2 generate_mirror_uv(int2 coord, bool even_x, bool even_y,
|
||||
int2 resolution, float2 inv_resolution)
|
||||
{
|
||||
coord.x -= int(even_x && coord.x < 0);
|
||||
coord.y -= int(even_y && coord.y < 0);
|
||||
coord += 1;
|
||||
coord.x += int(!even_x && coord.x >= resolution.x);
|
||||
coord.y += int(!even_y && coord.y >= resolution.y);
|
||||
float2 uv = float2(coord) * inv_resolution;
|
||||
return uv.yx;
|
||||
}
|
||||
|
||||
static inline void write_shared_4x4(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
int2 coord, float4 t0, float4 t1, float4 t2, float4 t3)
|
||||
{
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 0, float2(t0.x, t2.x));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 1, float2(t1.x, t3.x));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 2, float2(t0.y, t2.y));
|
||||
store_shared(blk, coord.y + 0, 2 * coord.x + 3, float2(t1.y, t3.y));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 0, float2(t0.z, t2.z));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 1, float2(t1.z, t3.z));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 2, float2(t0.w, t2.w));
|
||||
store_shared(blk, coord.y + 1, 2 * coord.x + 3, float2(t1.w, t3.w));
|
||||
}
|
||||
|
||||
// textureGather(...).wxzy — Metal's gather returns the same counter-clockwise-from-(i0,j1)
|
||||
// component order as Vulkan, so the reorder is identical.
|
||||
static inline float4 gather_layer(texture2d_array<float, access::sample> tex, sampler smp,
|
||||
float2 uv, uint layer)
|
||||
{
|
||||
float4 g = tex.gather(smp, uv, layer);
|
||||
return float4(g.w, g.x, g.z, g.y);
|
||||
}
|
||||
|
||||
static void load_image_with_apron(texture2d_array<float, access::sample> tex, sampler smp,
|
||||
threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
uint local_index, uint2 wg_id,
|
||||
int2 resolution, float2 inv_resolution)
|
||||
{
|
||||
int2 base_coord = int2(wg_id) * BLOCK_SIZE_HALF - APRON_HALF;
|
||||
int2 local_coord0 = 2 * unswizzle8x8(local_index);
|
||||
int2 coord0 = base_coord + local_coord0;
|
||||
|
||||
// Band layers gathered in 0/2/1/3 order (LL/LH/HL/HH interleave for the 2x2 scatter).
|
||||
float4 texels0 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, true, resolution, inv_resolution), 0);
|
||||
float4 texels1 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, true, resolution, inv_resolution), 2);
|
||||
float4 texels2 = gather_layer(tex, smp, generate_mirror_uv(coord0, true, false, resolution, inv_resolution), 1);
|
||||
float4 texels3 = gather_layer(tex, smp, generate_mirror_uv(coord0, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord0, texels0, texels1, texels2, texels3);
|
||||
|
||||
int2 local_coord_horiz = int2(BLOCK_SIZE_HALF + 2 * int(local_index % 2u), 2 * int(local_index / 2u));
|
||||
if (local_coord_horiz.y < BLOCK_SIZE_HALF + 2 * APRON_HALF)
|
||||
{
|
||||
int2 c = base_coord + local_coord_horiz;
|
||||
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord_horiz, texels0, texels1, texels2, texels3);
|
||||
}
|
||||
|
||||
int2 local_coord_vert = local_coord_horiz.yx;
|
||||
if (local_coord_vert.x < BLOCK_SIZE_HALF)
|
||||
{
|
||||
int2 c = base_coord + local_coord_vert;
|
||||
texels0 = gather_layer(tex, smp, generate_mirror_uv(c, true, true, resolution, inv_resolution), 0);
|
||||
texels1 = gather_layer(tex, smp, generate_mirror_uv(c, false, true, resolution, inv_resolution), 2);
|
||||
texels2 = gather_layer(tex, smp, generate_mirror_uv(c, true, false, resolution, inv_resolution), 1);
|
||||
texels3 = gather_layer(tex, smp, generate_mirror_uv(c, false, false, resolution, inv_resolution), 3);
|
||||
write_shared_4x4(blk, local_coord_vert, texels0, texels1, texels2, texels3);
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
}
|
||||
|
||||
static void inverse_transform8x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS], uint local_index)
|
||||
{
|
||||
const int SIZE = 8;
|
||||
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||
float2 values[PADDED_SIZE];
|
||||
|
||||
int2 local_coord = int2(8 * int(local_index % 4u), int(local_index / 4u));
|
||||
|
||||
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||
{
|
||||
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||
values[i + 0] = v0 * K;
|
||||
values[i + 1] = v1 * inv_K;
|
||||
}
|
||||
|
||||
// CDF 9/7 inverse lifting steps.
|
||||
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||
|
||||
// Avoid WAR hazard.
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||
{
|
||||
float2 a = values[2 * i + 0];
|
||||
float2 b = values[2 * i + 1];
|
||||
|
||||
// Transpose the 2x2 block, transpose write.
|
||||
float2 t0 = float2(a.x, b.x);
|
||||
float2 t1 = float2(a.y, b.y);
|
||||
|
||||
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||
}
|
||||
}
|
||||
|
||||
static void inverse_transform4x2(threadgroup half2 (&blk)[SHARED_ROWS][SHARED_COLS],
|
||||
uint local_index, bool active_lane, int y_offset)
|
||||
{
|
||||
const int SIZE = 4;
|
||||
const int PADDED_SIZE = SIZE + 2 * APRON;
|
||||
const int PADDED_SIZE_HALF = PADDED_SIZE / 2;
|
||||
float2 values[PADDED_SIZE];
|
||||
|
||||
int2 local_coord = int2(4 * int(local_index % 8u), int(local_index / 8u) + y_offset);
|
||||
|
||||
if (active_lane)
|
||||
{
|
||||
for (int i = 0; i < PADDED_SIZE; i += 2)
|
||||
{
|
||||
float2 v0 = load_shared(blk, local_coord.y, local_coord.x + i + 0);
|
||||
float2 v1 = load_shared(blk, local_coord.y, local_coord.x + i + 1);
|
||||
values[i + 0] = v0 * K;
|
||||
values[i + 1] = v1 * inv_K;
|
||||
}
|
||||
|
||||
for (int i = 2; i < PADDED_SIZE - 1; i += 2)
|
||||
values[i] -= DELTA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 3; i < PADDED_SIZE - 2; i += 2)
|
||||
values[i] -= GAMMA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 4; i < PADDED_SIZE - 3; i += 2)
|
||||
values[i] -= BETA * (values[i - 1] + values[i + 1]);
|
||||
for (int i = 5; i < PADDED_SIZE - 4; i += 2)
|
||||
values[i] -= ALPHA * (values[i - 1] + values[i + 1]);
|
||||
}
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
if (active_lane)
|
||||
{
|
||||
for (int i = APRON_HALF; i < PADDED_SIZE_HALF - APRON_HALF; i++)
|
||||
{
|
||||
float2 a = values[2 * i + 0];
|
||||
float2 b = values[2 * i + 1];
|
||||
|
||||
float2 t0 = float2(a.x, b.x);
|
||||
float2 t1 = float2(a.y, b.y);
|
||||
|
||||
int y_coord = (local_coord.x >> 1) + (i - APRON_HALF);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 0, t0);
|
||||
store_shared(blk, y_coord, 2 * local_coord.y + 1, t1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kernel void idwt(
|
||||
texture2d_array<float, access::sample> uTexture [[texture(0)]],
|
||||
texture2d<float, access::write> uOutput [[texture(1)]],
|
||||
sampler uSampler [[sampler(0)]],
|
||||
constant IdwtRegisters ®isters [[buffer(0)]],
|
||||
uint3 wg_id [[threadgroup_position_in_grid]],
|
||||
uint local_index [[thread_index_in_threadgroup]])
|
||||
{
|
||||
threadgroup half2 shared_block[SHARED_ROWS][SHARED_COLS];
|
||||
|
||||
load_image_with_apron(uTexture, uSampler, shared_block, local_index, wg_id.xy,
|
||||
registers.resolution, registers.inv_resolution);
|
||||
|
||||
// Horizontal transform.
|
||||
inverse_transform8x2(shared_block, local_index);
|
||||
|
||||
// Also need to transform the apron.
|
||||
inverse_transform4x2(shared_block, local_index, local_index < 32, BLOCK_SIZE_HALF);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
// Vertical transform.
|
||||
inverse_transform8x2(shared_block, local_index);
|
||||
|
||||
threadgroup_barrier(mem_flags::mem_threadgroup);
|
||||
|
||||
int2 local_coord = unswizzle8x8(local_index);
|
||||
|
||||
for (int y = local_coord.y; y < BLOCK_SIZE_HALF; y += 8)
|
||||
{
|
||||
for (int x = local_coord.x; x < BLOCK_SIZE; x += 8)
|
||||
{
|
||||
float2 v = load_shared(shared_block, y, x);
|
||||
if (DCShift)
|
||||
v += 0.5f;
|
||||
// Transposed store (wg_id.yx) — undoes the transpose-on-load; out-of-range writes
|
||||
// at the aligned-size overhang are dropped by Metal (matching the Vulkan behavior).
|
||||
int2 out0 = int2(2 * y + 0, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||
int2 out1 = int2(2 * y + 1, x) + BLOCK_SIZE * int2(int(wg_id.y), int(wg_id.x));
|
||||
uOutput.write(float4(v.x), uint2(out0));
|
||||
uOutput.write(float4(v.y), uint2(out1));
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -37,6 +37,7 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Metal
|
||||
import QuartzCore
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
|
||||
@@ -249,6 +250,28 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
|
||||
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
|
||||
/// `init`, before the connection exists) can capture it, then `start` binds the live connection +
|
||||
/// the arming flag once known — the same "reference captured in init, configured in start" shape as
|
||||
/// `recovery`/`gate`. `record` runs on VideoToolbox's callback thread; `bind` runs once on the main
|
||||
/// thread before the pump feeds the first AU, so the plain fields are safe (set-once, then read).
|
||||
private final class DecodeReport: @unchecked Sendable {
|
||||
private weak var connection: PunktfunkConnection?
|
||||
private var enabled = false
|
||||
func bind(_ connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
self.enabled = connection.wantsDecodeLatency()
|
||||
}
|
||||
/// Report received→decoded for one frame, in µs. Both stamps are client `CLOCK_REALTIME`
|
||||
/// (no skew). Skips when the controller isn't armed, so it's free to call on every decode.
|
||||
func record(receivedNs: Int64, decodedNs: Int64) {
|
||||
guard enabled, let c = connection else { return }
|
||||
let us = (decodedNs - receivedNs) / 1000
|
||||
if us > 0 { c.reportDecodeUs(UInt32(min(us, Int64(UInt32.max)))) }
|
||||
}
|
||||
}
|
||||
|
||||
public final class Stage2Pipeline {
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
@@ -257,8 +280,12 @@ public final class Stage2Pipeline {
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||
/// binds the live connection + arming flag (see DecodeReport).
|
||||
private let decodeReport = DecodeReport()
|
||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
@@ -306,11 +333,13 @@ public final class Stage2Pipeline {
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
let decodeReport = decodeReport
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
@@ -318,6 +347,10 @@ public final class Stage2Pipeline {
|
||||
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||
// Same interval, reported to the core bitrate controller so Automatic caps at this
|
||||
// device's real decode limit instead of the network link ceiling. Every decoded
|
||||
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
|
||||
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
|
||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||
@@ -346,6 +379,7 @@ public final class Stage2Pipeline {
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
@@ -362,7 +396,21 @@ public final class Stage2Pipeline {
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let reanchorGate = gate
|
||||
let thread = Thread {
|
||||
// PyroWave rides a different decode half: no CMFormatDescription/VideoToolbox machinery
|
||||
// (a wavelet AU has no parameter sets), no keyframe recovery or re-anchor freeze (the
|
||||
// stream is all-intra and Phase 4's partial delivery WANTS lossy frames on glass as
|
||||
// localized blur, not a freeze). The ready ring, render thread, pacing and meters are
|
||||
// shared unchanged.
|
||||
let thread: Thread
|
||||
if connection.videoCodec == .pyrowave {
|
||||
thread = Self.makePyroWavePump(
|
||||
connection: connection, token: token, pumpStopped: pumpStopped,
|
||||
ring: ring, renderSignal: renderSignal,
|
||||
device: presenter.metalDevice, queue: presenter.metalQueue,
|
||||
decodeMeter: decodeMeter,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
} else {
|
||||
thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
|
||||
@@ -445,6 +493,7 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread.name = "punktfunk-stage2-pump"
|
||||
thread.qualityOfService = .userInteractive
|
||||
@@ -504,9 +553,7 @@ public final class Stage2Pipeline {
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let rendered = presenter.render(
|
||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||
) { presentedNs in
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||
if let gate {
|
||||
@@ -525,6 +572,18 @@ public final class Stage2Pipeline {
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
debugStats?.presented(atNs: presentedNs)
|
||||
}
|
||||
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
|
||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||
let rendered: Bool
|
||||
switch frame.image {
|
||||
case .video(let pixelBuffer, let isHDR):
|
||||
rendered = presenter.render(
|
||||
pixelBuffer, isHDR: isHDR, presentAtMediaTime: presentAt,
|
||||
onPresented: onGlass)
|
||||
case .planar(let planes):
|
||||
rendered = presenter.renderPlanar(
|
||||
planes, presentAtMediaTime: presentAt, onPresented: onGlass)
|
||||
}
|
||||
debugStats?.renderReturned(
|
||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||
if !rendered {
|
||||
@@ -592,6 +651,93 @@ public final class Stage2Pipeline {
|
||||
renderSignal.signal() // wake the render thread so it can observe the stop and exit
|
||||
}
|
||||
|
||||
/// The PyroWave pump: AUs go straight into the Metal wavelet decoder (no VideoToolbox, no
|
||||
/// format descriptions), decoded planes ride the same ready ring / render thread. All-intra
|
||||
/// stream, so none of the VT pump's recovery machinery applies: keyframe/RFI requests are
|
||||
/// silenced host-side for this codec, and a lossy (partial-delivery) frame is MEANT to
|
||||
/// present as localized blur — never a freeze. Static + capture-by-parameter for the same
|
||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||
private static func makePyroWavePump(
|
||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
) -> Thread {
|
||||
// The chunk-aligned parse window = the session's negotiated shard payload (Welcome);
|
||||
// the 64-byte floor mirrors the Rust client's guard against a nonsense value.
|
||||
let windowSize = max(64, Int(connection.shardPayload))
|
||||
return Thread {
|
||||
defer { pumpStopped.signal() }
|
||||
// Compiles the two compute kernels on the session's first frames' thread — ~tens of
|
||||
// ms, once per session. Failure = this device can't run the negotiated codec (the
|
||||
// advertisement probe should have prevented this); end the session cleanly.
|
||||
guard let decoder = MetalWaveletDecoder(device: device, queue: queue) else {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
return
|
||||
}
|
||||
// Newest decoded frame index — a late partial (the reassembler's 30 ms fuse can
|
||||
// deliver one behind a newer complete frame) must not travel back in time.
|
||||
var newestIndex: UInt32?
|
||||
var lastDims: (w: Int, h: Int)?
|
||||
var alive = true
|
||||
while alive, !token.isStopped {
|
||||
alive = autoreleasepool { () -> Bool in
|
||||
do {
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
onFrame?(au)
|
||||
if let newest = newestIndex,
|
||||
Int32(bitPattern: au.frameIndex &- newest) <= 0 {
|
||||
return true // stale (or duplicate) frame — skip
|
||||
}
|
||||
guard !token.isStopped else { return true }
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
let receivedNs = au.receivedNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
) { planes in
|
||||
// Metal completed-handler thread — stamp + enqueue, don't block
|
||||
// (the exact contract of the VT output callback).
|
||||
guard let planes else { return }
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let decodedNs =
|
||||
Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0)
|
||||
ring.submit(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
image: .planar(planes), flags: flags))
|
||||
renderSignal.signal()
|
||||
}
|
||||
if submitted {
|
||||
newestIndex = au.frameIndex
|
||||
// Decoded-size changes come from the SOF dims (this is also how a
|
||||
// mid-stream Reconfigure lands here) — report like the VT pump.
|
||||
if let size = decoder.decodedSize,
|
||||
lastDims?.w != size.width || lastDims?.h != size.height {
|
||||
lastDims = (size.width, size.height)
|
||||
onDecodedSize?(size.width, size.height)
|
||||
}
|
||||
}
|
||||
// A dropped AU (malformed / SOF lost / too few blocks) is just skipped:
|
||||
// every PyroWave frame is independently decodable, the next one heals.
|
||||
return true
|
||||
} catch {
|
||||
if !token.isStopped { onSessionEnd?() }
|
||||
return false // session closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME`
|
||||
/// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target
|
||||
/// present time (when the frame is actually on glass), not the moment we drew.
|
||||
|
||||
@@ -12,7 +12,23 @@ import CoreVideo
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
|
||||
/// One decoded frame waiting to be presented. Owns a retained `CVPixelBuffer` until shown.
|
||||
/// A decoded frame's pixels — which present path they take. VideoToolbox codecs deliver a
|
||||
/// biplanar `CVPixelBuffer` (NV12/P010/444v/x444); the PyroWave Metal decoder delivers three
|
||||
/// separate R8 plane textures straight off its compute pass (there is no CVPixelBuffer — the
|
||||
/// planes never leave the GPU).
|
||||
public enum ReadyImage: @unchecked Sendable {
|
||||
/// 8-bit NV12 / 4:4:4 biplanar (SDR) or 10-bit P010 / x444 (HDR), Metal-compatible.
|
||||
/// `isHDR` = the stream is BT.2020 PQ and the presenter must configure EDR output.
|
||||
case video(CVPixelBuffer, isHDR: Bool)
|
||||
#if canImport(Metal)
|
||||
/// PyroWave planar output (Y full-res + Cb/Cr half-res, 8-bit SDR) with its precomputed
|
||||
/// CSC rows — presented by `MetalVideoPresenter.renderPlanar`.
|
||||
case planar(WaveletPlanes)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One decoded frame waiting to be presented. Owns its image (a retained `CVPixelBuffer`, or
|
||||
/// the PyroWave ring textures) until shown.
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
@@ -22,15 +38,26 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
/// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
|
||||
public let pixelBuffer: CVPixelBuffer
|
||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
||||
public let isHDR: Bool
|
||||
/// The decoded image and which present path it takes.
|
||||
public let image: ReadyImage
|
||||
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||
public let flags: UInt32
|
||||
|
||||
/// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor
|
||||
/// the decode round-trip tests assert against.)
|
||||
public var pixelBuffer: CVPixelBuffer? {
|
||||
if case .video(let buffer, _) = image { return buffer }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether this frame presents on the HDR path. PyroWave planar frames are 8-bit SDR by
|
||||
/// contract.
|
||||
public var isHDR: Bool {
|
||||
if case .video(_, let hdr) = image { return hdr }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
|
||||
@@ -286,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
onDecoded(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
|
||||
image: .video(imageBuffer, isHDR: isHDR), flags: flags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase {
|
||||
let ready = try XCTUnwrap(frame)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000)
|
||||
XCTAssertFalse(ready.isHDR)
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180)
|
||||
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180)
|
||||
XCTAssertEqual(
|
||||
CVPixelBufferGetPixelFormatType(ready.pixelBuffer),
|
||||
CVPixelBufferGetPixelFormatType(buffer),
|
||||
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12")
|
||||
decoder.reset()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// PyroWave Metal decoder tests — two layers:
|
||||
//
|
||||
// 1. Bitstream/window-walk parser tests (pure CPU): hand-crafted packet streams assert the
|
||||
// exact wire semantics of pyrowave_decoder.cpp's push_packet walk + the Phase-4
|
||||
// chunk-aligned framing (4-byte window prefix, FRAG chains, zeroed missing shards).
|
||||
//
|
||||
// 2. Golden-frame PSNR tests (Metal GPU): host-encoded fixtures (crates/punktfunk-host
|
||||
// encode/linux/pyrowave.rs `pyrowave_dump_golden`, run on a Vulkan box) decoded by the
|
||||
// Metal port and PSNR-matched against upstream's own decoder output. Float wavelet math is
|
||||
// not bit-exact across implementations (upstream ships precision variants), so the gate is
|
||||
// PSNR, not equality. This is the §4.7 validation oracle for the hand-ported kernels —
|
||||
// the gather/mirror addressing in idwt is the spot most likely to drift.
|
||||
|
||||
#if canImport(Metal)
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class PyroWaveParserTests: XCTestCase {
|
||||
// 256x144 → aligned 256x160; block space identical to the committed fixtures.
|
||||
private let width = 256
|
||||
private let height = 144
|
||||
|
||||
/// A BitstreamSequenceHeader (START_OF_FRAME) for `width`x`height`, 4:2:0 BT.709 limited.
|
||||
private func sof(totalBlocks: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||
let word0 =
|
||||
UInt32(width - 1) | (UInt32(height - 1) << 14) | (sequence << 28) | (1 << 31)
|
||||
// code=0 (SOF), chroma=0 (420), primaries/trc/matrix=0 (BT.709), range=1 (LIMITED),
|
||||
// siting=0.
|
||||
let word1 = UInt32(totalBlocks) | (1 << 30)
|
||||
return le32(word0) + le32(word1)
|
||||
}
|
||||
|
||||
/// A minimal coefficient packet: ballot=0 (all 8x8 blocks empty — legal and decodable),
|
||||
/// payload_words=2 (header only).
|
||||
private func packet(blockIndex: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||||
let word0 = UInt32(0) | (2 << 16) | (sequence << 28)
|
||||
let word1 = UInt32(0) | (UInt32(blockIndex) << 8)
|
||||
return le32(word0) + le32(word1)
|
||||
}
|
||||
|
||||
private func le32(_ v: UInt32) -> [UInt8] {
|
||||
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8(v >> 24)]
|
||||
}
|
||||
|
||||
/// Wrap bodies into `windowSize`-sized windows with the 4-byte used/kind prefix.
|
||||
private func window(_ body: [UInt8], kind: UInt16, size: Int) -> [UInt8] {
|
||||
precondition(body.count + 4 <= size)
|
||||
var out = [UInt8(body.count & 0xff), UInt8(body.count >> 8)]
|
||||
out += [UInt8(kind & 0xff), UInt8(kind >> 8)]
|
||||
out += body
|
||||
out += [UInt8](repeating: 0, count: size - out.count)
|
||||
return out
|
||||
}
|
||||
|
||||
func testLayoutMatchesUpstreamBlockSpace() {
|
||||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
XCTAssertEqual(layout.alignedWidth, 256)
|
||||
XCTAssertEqual(layout.alignedHeight, 160)
|
||||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||||
XCTAssertEqual(layout.levelHeight(0), 80)
|
||||
XCTAssertEqual(layout.levelWidth(4), 8)
|
||||
XCTAssertEqual(layout.levelHeight(4), 5)
|
||||
// Hand-summed: L4 (8x5 → 1 block) × 3 comps × 4 bands = 12; L3 (16x10 → 1) × 9 = 9;
|
||||
// L2 (32x20 → 1) × 9 = 9; L1 (64x40 → 2x2=4... ) — trust the invariant instead:
|
||||
// every band's count is ceil(w8/4)*ceil(h8/4) and the total is their sum.
|
||||
var expected = 0
|
||||
for level in stride(from: 4, through: 0, by: -1) {
|
||||
let w8 = (layout.levelWidth(level) + 7) / 8
|
||||
let h8 = (layout.levelHeight(level) + 7) / 8
|
||||
let per = ((w8 + 3) / 4) * ((h8 + 3) / 4)
|
||||
for component in 0..<3 {
|
||||
if level == 0 && component != 0 { continue }
|
||||
expected += per * (level == 4 ? 4 : 3)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(layout.blockCount32, expected)
|
||||
// The finest luma level's stride is its 32-block row width.
|
||||
XCTAssertEqual(layout.blockMeta[0][0][1].stride, (128 + 31) / 32)
|
||||
// Level-0 chroma is not coded in 4:2:0.
|
||||
XCTAssertEqual(layout.blockMeta[1][0][1].offset, -1)
|
||||
}
|
||||
|
||||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||||
let layout = WaveletLayout(width: width, height: height)
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 3)
|
||||
au += packet(blockIndex: 3) // duplicate — first wins, not double-counted
|
||||
au += packet(blockIndex: layout.blockCount32 - 1)
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
XCTAssertEqual(frame.layout.width, width)
|
||||
XCTAssertEqual(frame.totalBlocks, 4)
|
||||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||
XCTAssertEqual(frame.offsets[0], 0)
|
||||
XCTAssertEqual(frame.offsets[3], 2) // u32 words: each header-only packet is 2 words
|
||||
XCTAssertEqual(frame.offsets[1], UInt32.max)
|
||||
XCTAssertEqual(frame.payload.count, 6)
|
||||
XCTAssertFalse(frame.bt2020)
|
||||
XCTAssertFalse(frame.fullRange) // range bit 1 = LIMITED
|
||||
}
|
||||
|
||||
func testHalfOrFewerBlocksIsDropped() {
|
||||
var au = sof(totalBlocks: 4)
|
||||
au += packet(blockIndex: 0)
|
||||
au += packet(blockIndex: 1)
|
||||
// 2 of 4 decoded = exactly half — upstream requires MORE than half.
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testMissingSOFIsDropped() {
|
||||
let au = packet(blockIndex: 0) + packet(blockIndex: 1)
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testTruncatedPacketIsRejected() {
|
||||
var au = sof(totalBlocks: 1)
|
||||
// Claims 4 payload words but only the 8-byte header follows.
|
||||
let word0 = UInt32(0) | (4 << 16) | (1 << 28)
|
||||
au += le32(word0) + le32(0)
|
||||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||||
}
|
||||
|
||||
func testWindowWalkPackedFragAndMissingShard() throws {
|
||||
let size = 64
|
||||
// Window 1: SOF + one packet, PACKED. Window 2: a FRAG chain carrying one packet split
|
||||
// across two windows. Window 3: all zeros (a lost shard of a partial frame). Window 4:
|
||||
// a PACKED packet — the chain break must not eat it.
|
||||
let fragPacket = packet(blockIndex: 2)
|
||||
var au = window(sof(totalBlocks: 3) + packet(blockIndex: 0), kind: 0, size: size)
|
||||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||
au += window(Array(fragPacket[5...]), kind: 3, size: size)
|
||||
au += [UInt8](repeating: 0, count: size) // missing shard
|
||||
au += window(packet(blockIndex: 1), kind: 0, size: size)
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||||
XCTAssertEqual(frame.offsets[0], 0)
|
||||
XCTAssertEqual(frame.offsets[2], 2)
|
||||
XCTAssertEqual(frame.offsets[1], 4)
|
||||
}
|
||||
|
||||
func testBrokenFragChainIsDiscarded() throws {
|
||||
let size = 64
|
||||
let fragPacket = packet(blockIndex: 2)
|
||||
var au = window(sof(totalBlocks: 1) + packet(blockIndex: 0), kind: 0, size: size)
|
||||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||||
au += [UInt8](repeating: 0, count: size) // the chain's middle shard was lost
|
||||
au += window(Array(fragPacket[5...]), kind: 3, size: size) // dangling LAST — dropped
|
||||
let frame = try XCTUnwrap(
|
||||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||||
XCTAssertEqual(frame.decodedBlocks, 1)
|
||||
XCTAssertEqual(frame.offsets[2], UInt32.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Golden-frame decode against the committed host-encoder fixtures. Skipped when the machine
|
||||
/// has no Metal device (headless CI) — everywhere else this is the hand-ported kernels' guard.
|
||||
final class PyroWaveGoldenTests: XCTestCase {
|
||||
private static let fixtureDir = "PyroWaveFixtures"
|
||||
|
||||
private func fixture(_ name: String) throws -> Data {
|
||||
let url = try XCTUnwrap(
|
||||
Bundle.module.url(
|
||||
forResource: name, withExtension: "bin", subdirectory: Self.fixtureDir),
|
||||
"missing fixture \(name).bin — regenerate with pyrowave_dump_golden")
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
/// Completion box — the decode callback lands on a Metal thread.
|
||||
private final class ResultBox: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var planes: WaveletPlanes?
|
||||
}
|
||||
|
||||
/// Decode `au` synchronously and read all three planes back to CPU bytes.
|
||||
private func decode(
|
||||
au: Data, chunkAligned: Bool, windowSize: Int
|
||||
) throws -> (y: [UInt8], cb: [UInt8], cr: [UInt8]) {
|
||||
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
|
||||
let queue = try XCTUnwrap(device.makeCommandQueue())
|
||||
let decoder = try XCTUnwrap(MetalWaveletDecoder(device: device, queue: queue))
|
||||
let done = expectation(description: "decode completes")
|
||||
let box = ResultBox()
|
||||
let submitted = decoder.decode(
|
||||
au: au, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
) { planes in
|
||||
box.lock.lock()
|
||||
box.planes = planes
|
||||
box.lock.unlock()
|
||||
done.fulfill()
|
||||
}
|
||||
XCTAssertTrue(submitted, "the fixture AU must parse")
|
||||
wait(for: [done], timeout: 10)
|
||||
box.lock.lock()
|
||||
let result = box.planes
|
||||
box.lock.unlock()
|
||||
let planes = try XCTUnwrap(result, "the GPU pass must complete without error")
|
||||
return (
|
||||
try readback(planes.y, device: device, queue: queue),
|
||||
try readback(planes.cb, device: device, queue: queue),
|
||||
try readback(planes.cr, device: device, queue: queue)
|
||||
)
|
||||
}
|
||||
|
||||
private func readback(
|
||||
_ texture: MTLTexture, device: MTLDevice, queue: MTLCommandQueue
|
||||
) throws -> [UInt8] {
|
||||
let bytesPerRow = texture.width
|
||||
let length = bytesPerRow * texture.height
|
||||
let buffer = try XCTUnwrap(device.makeBuffer(length: length, options: .storageModeShared))
|
||||
let cmd = try XCTUnwrap(queue.makeCommandBuffer())
|
||||
let blit = try XCTUnwrap(cmd.makeBlitCommandEncoder())
|
||||
blit.copy(
|
||||
from: texture, sourceSlice: 0, sourceLevel: 0,
|
||||
sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
|
||||
sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1),
|
||||
to: buffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow,
|
||||
destinationBytesPerImage: length)
|
||||
blit.endEncoding()
|
||||
cmd.commit()
|
||||
cmd.waitUntilCompleted()
|
||||
return [UInt8](UnsafeRawBufferPointer(start: buffer.contents(), count: length))
|
||||
}
|
||||
|
||||
private func psnr(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
||||
precondition(a.count == b.count)
|
||||
var sse = 0.0
|
||||
for i in 0..<a.count {
|
||||
let d = Double(a[i]) - Double(b[i])
|
||||
sse += d * d
|
||||
}
|
||||
if sse == 0 { return .infinity }
|
||||
let mse = sse / Double(a.count)
|
||||
return 10 * log10(255.0 * 255.0 / mse)
|
||||
}
|
||||
|
||||
private func assertMatchesReference(
|
||||
_ decoded: (y: [UInt8], cb: [UInt8], cr: [UInt8]), prefix: String,
|
||||
file: StaticString = #filePath, line: UInt = #line
|
||||
) throws {
|
||||
for (name, plane, ref) in [
|
||||
("y", decoded.y, try fixture("\(prefix)-y")),
|
||||
("cb", decoded.cb, try fixture("\(prefix)-cb")),
|
||||
("cr", decoded.cr, try fixture("\(prefix)-cr")),
|
||||
] {
|
||||
XCTAssertEqual(plane.count, ref.count, file: file, line: line)
|
||||
let db = psnr(plane, [UInt8](ref))
|
||||
print("pyrowave golden \(prefix) \(name): \(db) dB")
|
||||
// The Metal port and upstream's decoder run the same math at the same precision
|
||||
// tier; residual differences are float rounding + the gather/mirror edge handling.
|
||||
// Well-matched ports measure ≫50 dB; 45 catches a real divergence long before it
|
||||
// is visible.
|
||||
XCTAssertGreaterThan(db, 45.0, "plane PSNR \(db) dB", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
func testDenseGoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-dense")
|
||||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||||
try assertMatchesReference(decoded, prefix: "ref-dense")
|
||||
}
|
||||
|
||||
func testChunkAlignedGoldenFrame() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
let au = try fixture("au-chunked")
|
||||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||||
}
|
||||
|
||||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||||
/// localized blur, not garbage).
|
||||
func testPartialFrameStillDecodes() throws {
|
||||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||||
var au = try fixture("au-chunked")
|
||||
let windows = au.count / 1408
|
||||
try XCTSkipIf(windows < 3, "fixture too small to punch a hole in")
|
||||
let hole = (windows / 2) * 1408
|
||||
au.replaceSubrange(hole..<(hole + 1408), with: [UInt8](repeating: 0, count: 1408))
|
||||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||||
let ref = try fixture("ref-chunked-y")
|
||||
let db = psnr(decoded.y, [UInt8](ref))
|
||||
XCTAssertGreaterThan(db, 25.0, "lossy frame should still resemble the source (\(db) dB)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -47,18 +47,21 @@ final class Stage444Tests: XCTestCase {
|
||||
box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock()
|
||||
XCTAssertNil(error.map { "decode error \($0)" })
|
||||
let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256)
|
||||
let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer)
|
||||
guard case .video(let buffer, let isHDR) = ready.image else {
|
||||
return XCTFail("a VideoToolbox decode must deliver a .video frame")
|
||||
}
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), 256)
|
||||
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||
XCTAssertTrue(
|
||||
pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange
|
||||
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
|
||||
"expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))")
|
||||
XCTAssertFalse(ready.isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||
XCTAssertFalse(isHDR, "an 8-bit BT.709 4:4:4 stream is SDR")
|
||||
// The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is
|
||||
// what lets the unchanged shader sample chroma at the luma UV.
|
||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetWidthOfPlane(buffer, 1), 256)
|
||||
XCTAssertEqual(CVPixelBufferGetHeightOfPlane(buffer, 1), 256)
|
||||
}
|
||||
|
||||
private func fourCCString(_ t: OSType) -> String {
|
||||
|
||||
@@ -99,8 +99,9 @@ final class VideoToolboxRoundTripTests: XCTestCase {
|
||||
box.lock.unlock()
|
||||
XCTAssertNil(error.map { "decode error \($0)" })
|
||||
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
|
||||
let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame")
|
||||
XCTAssertEqual(CVPixelBufferGetWidth(buffer), width)
|
||||
XCTAssertEqual(CVPixelBufferGetHeight(buffer), height)
|
||||
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
|
||||
XCTAssertEqual(
|
||||
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
|
||||
|
||||
@@ -128,7 +128,10 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
eprintln!(
|
||||
"pairing failed: {} ({e:?})",
|
||||
crate::trust::pair_error_message(&e)
|
||||
);
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +214,10 @@ pub fn pin_dialog(
|
||||
};
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
||||
// rejection) — never blame the PIN for a dead network path.
|
||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||||
.map_err(|e| trust::pair_error_message(&e));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
|
||||
@@ -13,7 +13,12 @@ name = "punktfunk-session"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["ui"]
|
||||
default = ["ui", "pyrowave"]
|
||||
# PyroWave client decode (the wired-LAN wavelet codec) — enables the decode backend + the
|
||||
# planar present path. ON by default; each session still opts in explicitly (the Settings
|
||||
# codec pick, or PUNKTFUNK_PREFER_PYROWAVE=1). The Windows ARM64 leg builds
|
||||
# --no-default-features and so skips it (video decode is Linux-only anyway).
|
||||
pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
|
||||
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
|
||||
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
|
||||
# stats on stdout only.
|
||||
|
||||
@@ -421,18 +421,9 @@ impl ServiceState {
|
||||
console.set_pair(PairPhase::Paired { key: fp_hex });
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = match e {
|
||||
punktfunk_core::PunktfunkError::Crypto => {
|
||||
"Wrong PIN — check the host's Pairing page and try again."
|
||||
.to_string()
|
||||
}
|
||||
punktfunk_core::PunktfunkError::Timeout => {
|
||||
"The host didn't answer. Is it running and reachable?"
|
||||
.to_string()
|
||||
}
|
||||
other => format!("Pairing failed: {other:?}"),
|
||||
};
|
||||
console.set_pair(PairPhase::Failed(msg));
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable
|
||||
// vs a typed host rejection) — shared with every other surface.
|
||||
console.set_pair(PairPhase::Failed(trust::pair_error_message(&e)));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,7 +64,9 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
}
|
||||
Err(e) => {
|
||||
st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
|
||||
// Cause-specific: wrong PIN vs pairing-not-armed vs unreachable —
|
||||
// never blame the PIN for a dead network path (shared wording).
|
||||
st.call(trust::pair_error_message(&e));
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_mac, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,11 @@ tracing = "0.1"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pipewire = "0.9"
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# PyroWave decode (the opt-in wired-LAN wavelet codec, design/pyrowave-codec-plan.md
|
||||
# §4.5) — pure Vulkan compute on the presenter's shared device. `ash` only wraps the
|
||||
# presenter's existing raw handles (same pinned version as pf-presenter).
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
ash = { version = "0.38", optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
@@ -57,3 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
# method itself is feature-gated behind this.
|
||||
"Win32_Security",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# PyroWave client decode ships in every default build (flatpak included; pyrowave-sys is a
|
||||
# vendored in-repo tree, offline-safe, and an empty stub off Linux/Windows). The codec is
|
||||
# still strictly per-session opt-in (Settings codec pick / PUNKTFUNK_PREFER_PYROWAVE=1).
|
||||
default = ["pyrowave"]
|
||||
pyrowave = ["dep:pyrowave-sys", "dep:ash"]
|
||||
|
||||
@@ -269,6 +269,8 @@ impl PadInfo {
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
GamepadPref::SteamController2 => "Steam Controller 2",
|
||||
GamepadPref::SteamController2Puck => "Steam Controller 2 Puck",
|
||||
GamepadPref::SwitchPro => "Switch Pro",
|
||||
_ => "",
|
||||
}
|
||||
@@ -1606,7 +1608,8 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
HidOutput::Led { pad, .. }
|
||||
| HidOutput::PlayerLeds { pad, .. }
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. } => *pad,
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1924,5 +1927,13 @@ mod slot_tests {
|
||||
}),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::HidRaw {
|
||||
pad: 6,
|
||||
kind: 0,
|
||||
data: vec![0x80, 0, 0]
|
||||
}),
|
||||
6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,11 @@ pub mod session;
|
||||
pub mod trust;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod video;
|
||||
// PyroWave decode — Linux + `pyrowave` feature only (plan §4.5; the Windows client's
|
||||
// present-path decision and the Apple Metal port are their own phases).
|
||||
#[cfg(windows)]
|
||||
pub mod video_d3d11;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub mod video_pyrowave;
|
||||
|
||||
pub mod wol;
|
||||
|
||||
@@ -211,6 +211,21 @@ fn pump(
|
||||
frame_tx: async_channel::Sender<DecodedFrame>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// PUNKTFUNK_PREFER_PYROWAVE=1 — the Phase-2 lab opt-in for the wired-LAN wavelet codec
|
||||
// (a Settings toggle is the Phase-3 productization). Riding `preferred_codec` is exactly
|
||||
// the plan-§3 contract: the host only ever picks PyroWave when the client names it.
|
||||
#[allow(unused_mut)]
|
||||
let mut preferred = params.preferred_codec;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if std::env::var("PUNKTFUNK_PREFER_PYROWAVE").as_deref() == Ok("1") {
|
||||
if params.vulkan.as_ref().is_some_and(|v| v.pyrowave_decode) {
|
||||
preferred = punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_PREFER_PYROWAVE=1 but the presenter device failed the pyrowave probe — keeping the normal codec preference"
|
||||
);
|
||||
}
|
||||
}
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -220,8 +235,9 @@ fn pump(
|
||||
params.bitrate_kbps,
|
||||
params.video_caps,
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
// FFmpeg's codecs plus CODEC_PYROWAVE when the presenter device passed the probe.
|
||||
crate::video::decodable_codecs_for(params.vulkan.as_ref()),
|
||||
preferred, // the user's soft codec preference (0 = auto; see the pyrowave opt-in above)
|
||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||
@@ -239,6 +255,10 @@ fn pump(
|
||||
.to_string()
|
||||
}
|
||||
PunktfunkError::Timeout => "Connection timed out".to_string(),
|
||||
// The host said WHY it turned us away (typed application close) — show that
|
||||
// verbatim instead of a generic failure: "the request was denied on the host"
|
||||
// and "connection timed out" call for very different next steps.
|
||||
PunktfunkError::Rejected(reason) => crate::trust::connect_reject_message(reason),
|
||||
other => format!("Connect failed: {other:?}"),
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Failed {
|
||||
@@ -262,7 +282,29 @@ fn pump(
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref()) {
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
let mode = connector.mode();
|
||||
match params.vulkan.as_ref() {
|
||||
Some(vk) => Decoder::new_pyrowave(
|
||||
vk,
|
||||
mode.width,
|
||||
mode.height,
|
||||
connector.shard_payload as usize,
|
||||
),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"pyrowave session without a presenter device"
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref())
|
||||
};
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let built = Decoder::new(codec_id, ¶ms.decoder, params.vulkan.as_ref());
|
||||
let mut decoder = match built {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
@@ -286,7 +328,23 @@ fn pump(
|
||||
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
// PUNKTFUNK_DEBUG_RECONFIGURE=WxH@HZ:SECS — lab lever: request ONE mid-stream mode
|
||||
// switch N seconds in, so a headless session (no window manager to drag a window in)
|
||||
// can exercise the resize path deterministically — host pipeline rebuild, decoder
|
||||
// follow-through (e.g. the PyroWave in-place rebuild), overlay/aspect handling.
|
||||
let pump_start = Instant::now();
|
||||
let mut debug_reconfig = std::env::var("PUNKTFUNK_DEBUG_RECONFIGURE")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
let parsed = parse_debug_reconfigure(&s);
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(value = %s, "PUNKTFUNK_DEBUG_RECONFIGURE not understood (want WxH@HZ:SECS) — ignored");
|
||||
}
|
||||
parsed
|
||||
});
|
||||
let mut total_frames = 0u64;
|
||||
// Newest frame index handed to the decoder — the staleness bar for late partials.
|
||||
let mut newest_decoded_idx: Option<u32> = None;
|
||||
let mut window_start = Instant::now();
|
||||
let mut frames_n = 0u32;
|
||||
let mut bytes_n = 0u64;
|
||||
@@ -294,6 +352,9 @@ fn pump(
|
||||
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Adaptive bitrate: report the decode stage back to the core controller only when it's armed
|
||||
// (Automatic, non-PyroWave). Constant for the session — resolve once, gate the per-frame call.
|
||||
let wants_decode = connector.wants_decode_latency();
|
||||
// Host/network split (Phase 2): frames awaiting their per-AU 0xCF host timing,
|
||||
// correlated by pts_ns. Bounded — an old host never sends any, so entries just age out.
|
||||
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
|
||||
@@ -324,6 +385,18 @@ fn pump(
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break None;
|
||||
}
|
||||
if let Some((mode, delay)) = debug_reconfig {
|
||||
if pump_start.elapsed() >= delay {
|
||||
tracing::info!(
|
||||
?mode,
|
||||
"PUNKTFUNK_DEBUG_RECONFIGURE: requesting mid-stream mode switch"
|
||||
);
|
||||
if let Err(e) = connector.request_mode(mode) {
|
||||
tracing::warn!(error = ?e, "debug mode switch request failed");
|
||||
}
|
||||
debug_reconfig = None;
|
||||
}
|
||||
}
|
||||
// 20 ms wait: audio has its own thread now, so this only bounds stop-flag
|
||||
// responsiveness and the per-iteration keyframe-recovery check (a frame arrives
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
@@ -395,7 +468,21 @@ fn pump(
|
||||
}
|
||||
None => next_expected_index = Some(frame.frame_index.wrapping_add(1)),
|
||||
}
|
||||
match decoder.decode(&frame.data) {
|
||||
// A PARTIAL that lost the race (a newer frame already decoded) is pure
|
||||
// time travel — skip it; each PyroWave frame is independent, so nothing
|
||||
// downstream needs it. Completes keep the normal path (reorder is handled
|
||||
// by the continuity gate).
|
||||
if !frame.complete
|
||||
&& newest_decoded_idx
|
||||
.is_some_and(|n: u32| n.wrapping_sub(frame.frame_index) <= u32::MAX / 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
newest_decoded_idx = Some(match newest_decoded_idx {
|
||||
Some(n) if frame.frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
|
||||
_ => frame.frame_index,
|
||||
});
|
||||
match decoder.decode_frame(&frame.data, frame.flags, frame.complete) {
|
||||
Ok(Some(image)) => {
|
||||
// Fold this decoded frame through the shared freeze gate: it reads the AU's
|
||||
// re-anchor wire flags (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT),
|
||||
@@ -413,6 +500,8 @@ fn pump(
|
||||
DecodedImage::VkFrame(_) => "vulkan",
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(_) => "d3d11va",
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(_) => "pyrowave",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
let (w, h, path) = match &image {
|
||||
@@ -422,6 +511,8 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height, "pyrowave"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
}
|
||||
@@ -486,6 +577,15 @@ fn pump(
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: feed the decoder-backlog signal every frame (the network
|
||||
// signals can't see the client's decoder). Uses the CPU-side decoded stamp:
|
||||
// exact for the synchronous D3D11VA/software path; received→submit for the
|
||||
// async Vulkan-Video path — still the decoder-input backpressure the rate
|
||||
// controller needs, without the per-frame fence wait the HUD stat avoids.
|
||||
if wants_decode {
|
||||
let us = decoded_ns.saturating_sub(received_ns) / 1000;
|
||||
connector.report_decode_us(us.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
}
|
||||
// The decoder produced nothing — under zero-reorder LOW_DELAY (one-in/one-out) that
|
||||
// means it's wedged on missing references with no reassembler drop to trigger
|
||||
@@ -703,3 +803,43 @@ fn spawn_audio(
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse the `PUNKTFUNK_DEBUG_RECONFIGURE` lab lever: `WxH@HZ:SECS` → request that mode
|
||||
/// SECS seconds into the stream (e.g. `1280x720@60:5`).
|
||||
fn parse_debug_reconfigure(s: &str) -> Option<(Mode, Duration)> {
|
||||
let (mode_s, secs_s) = s.split_once(':')?;
|
||||
let (res, hz) = mode_s.split_once('@')?;
|
||||
let (w, h) = res.split_once('x')?;
|
||||
let mode = Mode {
|
||||
width: w.trim().parse().ok()?,
|
||||
height: h.trim().parse().ok()?,
|
||||
refresh_hz: hz.trim().parse().ok()?,
|
||||
};
|
||||
Some((mode, Duration::from_secs(secs_s.trim().parse().ok()?)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_reconfigure_parses_the_documented_shape() {
|
||||
let (mode, delay) = parse_debug_reconfigure("1280x720@60:5").unwrap();
|
||||
assert_eq!((mode.width, mode.height, mode.refresh_hz), (1280, 720, 60));
|
||||
assert_eq!(delay, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_reconfigure_rejects_garbage() {
|
||||
for bad in [
|
||||
"",
|
||||
"1280x720",
|
||||
"1280x720@60",
|
||||
"x@:",
|
||||
"ax b@c:d",
|
||||
"1280x720@60:x",
|
||||
] {
|
||||
assert!(parse_debug_reconfigure(bad).is_none(), "{bad:?} parsed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,65 @@ pub fn pair_with_host(
|
||||
)
|
||||
}
|
||||
|
||||
/// User-facing sentence for a failed connect / request-access, keyed on the actual cause —
|
||||
/// shared by every desktop/console surface so "the host declined this device" never renders
|
||||
/// as "connection timed out". Reason-specific text for a typed host rejection
|
||||
/// ([`punktfunk_core::reject::RejectReason`]); the caller keeps its own wording for
|
||||
/// non-rejection errors.
|
||||
pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> String {
|
||||
use punktfunk_core::reject::RejectReason as R;
|
||||
match reason {
|
||||
R::Denied => "The host declined this device's request.".into(),
|
||||
R::ApprovalTimeout => {
|
||||
"Nobody approved the request on the host in time — approve this device in the \
|
||||
host's console or web UI, then request access again."
|
||||
.into()
|
||||
}
|
||||
R::Superseded => {
|
||||
"A newer request from this device replaced this one — approve the latest request \
|
||||
on the host."
|
||||
.into()
|
||||
}
|
||||
R::IdentityRequired => {
|
||||
"The host requires pairing — pair this device (PIN or request access) first.".into()
|
||||
}
|
||||
R::PairingNotArmed => {
|
||||
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try \
|
||||
again."
|
||||
.into()
|
||||
}
|
||||
R::PairingBoundToOtherDevice => {
|
||||
"The host's pairing window is armed for a different device — arm it for this one."
|
||||
.into()
|
||||
}
|
||||
R::PairingRateLimited => {
|
||||
"Too many pairing attempts — wait a couple of seconds and try again.".into()
|
||||
}
|
||||
R::WireVersionMismatch => {
|
||||
"Client and host versions don't match — update both to the same release.".into()
|
||||
}
|
||||
R::Busy => "The host is busy with another session.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing sentence for a failed PIN pairing ceremony ([`pair_with_host`]) — distinguishes
|
||||
/// a wrong PIN (the SPAKE2 proof failed) from an unreachable host and from the host's typed
|
||||
/// rejections, so a dead network path or a disarmed host is never reported as a bad PIN.
|
||||
pub fn pair_error_message(err: &punktfunk_core::PunktfunkError) -> String {
|
||||
use punktfunk_core::PunktfunkError as E;
|
||||
match err {
|
||||
E::Crypto => "Wrong PIN — check the PIN on the host's Pairing page and try again.".into(),
|
||||
E::Rejected(reason) => connect_reject_message(*reason),
|
||||
E::Timeout => "The host didn't answer. Is it running and reachable?".into(),
|
||||
E::Io(_) => {
|
||||
"Couldn't reach the host — check that this device and the host are on the same \
|
||||
network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
|
||||
.into()
|
||||
}
|
||||
other => format!("Pairing failed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
|
||||
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
|
||||
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
|
||||
@@ -515,6 +574,10 @@ impl Settings {
|
||||
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
||||
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
||||
"av1" => punktfunk_core::quic::CODEC_AV1,
|
||||
// The wired-LAN wavelet codec: preference-only by design (resolve_codec never
|
||||
// auto-picks it), and harmless on a build/device that doesn't advertise the
|
||||
// bit — the ladder falls back to HEVC.
|
||||
"pyrowave" => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -631,6 +694,9 @@ mod tests {
|
||||
assert!(s.mic_enabled);
|
||||
assert_eq!(s.decoder, "hardware");
|
||||
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
|
||||
let mut pw = s.clone();
|
||||
pw.codec = "pyrowave".into();
|
||||
assert_eq!(pw.preferred_codec(), punktfunk_core::quic::CODEC_PYROWAVE);
|
||||
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
|
||||
assert!(s.hdr_enabled);
|
||||
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
|
||||
|
||||
@@ -68,6 +68,11 @@ pub enum DecodedImage {
|
||||
/// (Intel's Windows driver foremost). See `crate::video_d3d11`.
|
||||
#[cfg(windows)]
|
||||
D3d11(crate::video_d3d11::D3d11Frame),
|
||||
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
/// One Vulkan-decoded frame. The image lives on the presenter's own VkDevice (the
|
||||
@@ -183,6 +188,8 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => f.keyframe,
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => f.keyframe,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => f.keyframe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +204,8 @@ impl DecodedImage {
|
||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +321,11 @@ enum Backend {
|
||||
Vaapi(VaapiDecoder),
|
||||
#[cfg(windows)]
|
||||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||||
/// no FFmpeg involvement. No demotion rung — there is no other decoder for it.
|
||||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
|
||||
@@ -360,6 +374,21 @@ pub fn decodable_codecs() -> u8 {
|
||||
bits
|
||||
}
|
||||
|
||||
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
||||
/// compute-feature probe. Advertisement-only: `resolve_codec` never auto-picks PyroWave —
|
||||
/// the session must also name it `preferred_codec` (plan §3), which the client does only
|
||||
/// under its explicit opt-in.
|
||||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
|
||||
let bits = decodable_codecs();
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let _ = vk;
|
||||
bits
|
||||
}
|
||||
|
||||
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
|
||||
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
|
||||
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
|
||||
@@ -539,6 +568,29 @@ impl Decoder {
|
||||
|
||||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
||||
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||||
/// HEVC so an — impossible — demotion path stays well-formed).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub fn new_pyrowave(
|
||||
vk: &VulkanDecodeDevice,
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
) -> Result<Decoder> {
|
||||
Ok(Decoder {
|
||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||
vk,
|
||||
width,
|
||||
height,
|
||||
shard_payload,
|
||||
)?)),
|
||||
codec_id: ffmpeg::codec::Id::HEVC,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn take_keyframe_request(&mut self) -> bool {
|
||||
std::mem::take(&mut self.want_keyframe)
|
||||
}
|
||||
@@ -566,12 +618,43 @@ impl Decoder {
|
||||
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||||
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||
self.decode_frame(au, 0, true)
|
||||
}
|
||||
|
||||
/// [`decode`](Self::decode) with the AU's wire facts: `user_flags` (chunk-aligned AUs
|
||||
/// are parsed in shard windows — [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`])
|
||||
/// and completeness (`false` = a partial delivery; only the PyroWave backend decodes
|
||||
/// those — as one frame of localized blur, plan §4.4).
|
||||
pub fn decode_frame(
|
||||
&mut self,
|
||||
au: &[u8],
|
||||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||||
#[cfg_attr(
|
||||
not(all(target_os = "linux", feature = "pyrowave")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
user_flags: u32,
|
||||
complete: bool,
|
||||
) -> Result<Option<DecodedImage>> {
|
||||
let result = match &mut self.backend {
|
||||
Backend::Vulkan(v) => v.decode(au).map(|f| f.map(DecodedImage::VkFrame)),
|
||||
Backend::Vulkan(v) => {
|
||||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||||
v.decode(au).map(|f| f.map(DecodedImage::VkFrame))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)),
|
||||
#[cfg(windows)]
|
||||
Backend::D3d11va(d) => d.decode(au).map(|f| f.map(DecodedImage::D3d11)),
|
||||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||||
// error; the pump surfaces it and the session falls back to HEVC by
|
||||
// renegotiation (plan §4.6), not by decoder swap.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
Backend::PyroWave(p) => {
|
||||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||||
return Ok(p
|
||||
.decode_frame(au, aligned, complete)?
|
||||
.map(DecodedImage::PyroWave));
|
||||
}
|
||||
Backend::Software(s) => return Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||||
};
|
||||
match result {
|
||||
@@ -1077,6 +1160,24 @@ pub struct VulkanDecodeDevice {
|
||||
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
|
||||
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
|
||||
pub video_decode: bool,
|
||||
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
|
||||
/// features its kernels need were present AND enabled at device creation
|
||||
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
|
||||
/// `CODEC_PYROWAVE` advertisement and the pyrowave decoder backend.
|
||||
pub pyrowave_decode: bool,
|
||||
/// The feature facts + creation shape the pyrowave decoder's pinned create-info
|
||||
/// reconstruction mirrors (pyrowave 0.4.0 requires the instance/device create infos —
|
||||
/// content-accurate, kept alive — to share our VkDevice).
|
||||
pub f_shader_int16: bool,
|
||||
pub f_storage_buffer8: bool,
|
||||
pub f_subgroup_size_control: bool,
|
||||
pub f_compute_full_subgroups: bool,
|
||||
pub f_shader_float16: bool,
|
||||
/// `VkPhysicalDeviceProperties::apiVersion` of the presenter's device.
|
||||
pub api_version: u32,
|
||||
/// The queue families the device was created with (one `VkDeviceQueueCreateInfo` each,
|
||||
/// one queue per family, priority 1.0) — mirrored by the reconstruction.
|
||||
pub queue_families: Vec<u32>,
|
||||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
||||
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||||
pub d3d11_import: bool,
|
||||
@@ -1598,6 +1699,14 @@ mod tests {
|
||||
f_sampler_ycbcr: true,
|
||||
f_timeline_semaphore: true,
|
||||
f_synchronization2: true,
|
||||
f_shader_int16: false,
|
||||
f_storage_buffer8: false,
|
||||
f_subgroup_size_control: false,
|
||||
f_compute_full_subgroups: false,
|
||||
f_shader_float16: false,
|
||||
api_version: 0,
|
||||
queue_families: Vec::new(),
|
||||
pyrowave_decode: false,
|
||||
video_decode: true,
|
||||
d3d11_import: false,
|
||||
adapter_luid: None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,11 +65,14 @@ const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("mutter", "Mutter"),
|
||||
("gamescope", "gamescope"),
|
||||
];
|
||||
const CODECS: [(&str, &str); 4] = [
|
||||
const CODECS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
("hevc", "HEVC"),
|
||||
("h264", "H.264"),
|
||||
("av1", "AV1"),
|
||||
// Opt-in wired-LAN low-latency codec (100-400 Mbps class, 8-bit SDR). Only ever
|
||||
// selected when the host supports it too; anything else falls back to HEVC.
|
||||
("pyrowave", "PyroWave (wired LAN)"),
|
||||
];
|
||||
const DECODERS: [(&str, &str); 4] = [
|
||||
("auto", "Automatic"),
|
||||
|
||||
@@ -43,3 +43,9 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# PyroWave planar present path (the wired-LAN wavelet codec) — forwards to the decode
|
||||
# backend in pf-client-core; ON by default, matching pf-client-core's default.
|
||||
default = ["pyrowave"]
|
||||
pyrowave = ["pf-client-core/pyrowave"]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Planar 3-plane YCbCr → RGBA — the PyroWave variant of nv12_csc.frag (separate Cb and
|
||||
// Cr R8 planes instead of an interleaved CbCr plane; design/pyrowave-codec-plan.md §4.5).
|
||||
// Same push-constant contract (csc_rows precomputes the matrix + range expansion), same
|
||||
// output modes — though PyroWave itself is 8-bit SDR BT.709 limited, keeping parity means
|
||||
// one less divergence if the codec ever signals more. 4:4:4 needs no shader change: the
|
||||
// chroma planes arrive full-res and the siting correction self-disables.
|
||||
//
|
||||
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 frag;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D u_y;
|
||||
layout(set = 0, binding = 1) uniform sampler2D u_cb;
|
||||
layout(set = 0, binding = 2) uniform sampler2D u_cr;
|
||||
|
||||
layout(push_constant) uniform Csc {
|
||||
vec4 r0;
|
||||
vec4 r1;
|
||||
vec4 r2;
|
||||
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
|
||||
} pc;
|
||||
|
||||
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
|
||||
// 10000 nits.
|
||||
vec3 pq_eotf(vec3 e) {
|
||||
const float m1 = 0.1593017578125; // 2610/16384
|
||||
const float m2 = 78.84375; // 2523/4096 * 128
|
||||
const float c1 = 0.8359375; // 3424/4096
|
||||
const float c2 = 18.8515625; // 2413/4096 * 32
|
||||
const float c3 = 18.6875; // 2392/4096 * 32
|
||||
vec3 p = pow(max(e, vec3(0.0)), vec3(1.0 / m2));
|
||||
return pow(max(p - c1, vec3(0.0)) / (c2 - c3 * p), vec3(1.0 / m1));
|
||||
}
|
||||
|
||||
// BT.2020 → BT.709 primaries (linear light).
|
||||
vec3 bt2020_to_709(vec3 c) {
|
||||
return mat3(
|
||||
1.6605, -0.1246, -0.0182,
|
||||
-0.5876, 1.1329, -0.1006,
|
||||
-0.0728, -0.0083, 1.1187
|
||||
) * c;
|
||||
}
|
||||
|
||||
// Linear → sRGB OETF.
|
||||
vec3 srgb_oetf(vec3 c) {
|
||||
c = clamp(c, 0.0, 1.0);
|
||||
bvec3 lo = lessThanEqual(c, vec3(0.0031308));
|
||||
vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
|
||||
return mix(hi, c * 12.92, vec3(lo));
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Left-cosited 4:2:0 chroma sampled at luma UV assumes CENTER siting — offset +0.25
|
||||
// chroma texels to re-align (same correction as nv12_csc.frag; self-disables when the
|
||||
// chroma plane is full-res).
|
||||
vec2 cuv = v_uv;
|
||||
int cw = textureSize(u_cb, 0).x;
|
||||
if (cw < textureSize(u_y, 0).x) {
|
||||
cuv.x += 0.25 / float(cw);
|
||||
}
|
||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_cb, cuv).r, texture(u_cr, cuv).r);
|
||||
vec3 rgb = vec3(
|
||||
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
||||
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
||||
dot(pc.r2.xyz, yuv) + pc.r2.w
|
||||
);
|
||||
|
||||
if (pc.params.x > 0.5) {
|
||||
vec3 lin = pq_eotf(clamp(rgb, 0.0, 1.0)) * (10000.0 / 203.0);
|
||||
lin = max(bt2020_to_709(lin), vec3(0.0));
|
||||
float peak = max(pc.params.y, 1.0001);
|
||||
float l = max(lin.r, max(lin.g, lin.b));
|
||||
if (l > 1.0) {
|
||||
float mapped = 1.0 + (l - 1.0) / (1.0 + (l - 1.0) / (peak - 1.0));
|
||||
lin *= mapped / l;
|
||||
}
|
||||
rgb = srgb_oetf(lin);
|
||||
} else {
|
||||
rgb = clamp(rgb, 0.0, 1.0);
|
||||
}
|
||||
frag = vec4(rgb, 1.0);
|
||||
}
|
||||
Binary file not shown.
@@ -32,6 +32,32 @@ impl CscPass {
|
||||
/// `attachment_format` = the video image's format: R8G8B8A8 for SDR, a 10-bit
|
||||
/// format when the pass writes PQ (8 bits would band the PQ curve visibly).
|
||||
pub fn new(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
|
||||
Self::build(
|
||||
device,
|
||||
attachment_format,
|
||||
2,
|
||||
include_bytes!("../shaders/nv12_csc.frag.spv"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The planar 3-plane variant (separate Cb/Cr R8 planes — the PyroWave decode
|
||||
/// output, design/pyrowave-codec-plan.md §4.5). Same push-constant contract.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
pub fn new_planar(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
|
||||
Self::build(
|
||||
device,
|
||||
attachment_format,
|
||||
3,
|
||||
include_bytes!("../shaders/planar_csc.frag.spv"),
|
||||
)
|
||||
}
|
||||
|
||||
fn build(
|
||||
device: &ash::Device,
|
||||
attachment_format: vk::Format,
|
||||
plane_bindings: u32,
|
||||
frag_spv: &[u8],
|
||||
) -> Result<CscPass> {
|
||||
// One color attachment: the presenter's video image. Content is fully
|
||||
// overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the
|
||||
// existing letterbox blit consumes it with no extra barrier.
|
||||
@@ -89,20 +115,16 @@ impl CscPass {
|
||||
}?;
|
||||
|
||||
let samplers = [sampler];
|
||||
let bindings = [
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.immutable_samplers(&samplers),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(1)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.immutable_samplers(&samplers),
|
||||
];
|
||||
let bindings: Vec<vk::DescriptorSetLayoutBinding> = (0..plane_bindings)
|
||||
.map(|b| {
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(b)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.immutable_samplers(&samplers)
|
||||
})
|
||||
.collect();
|
||||
let set_layout = unsafe {
|
||||
device.create_descriptor_set_layout(
|
||||
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||
@@ -124,7 +146,7 @@ impl CscPass {
|
||||
|
||||
let pool_sizes = [vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(2)];
|
||||
.descriptor_count(plane_bindings)];
|
||||
let desc_pool = unsafe {
|
||||
device.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
@@ -145,7 +167,7 @@ impl CscPass {
|
||||
device,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
include_bytes!("../shaders/nv12_csc.frag.spv"),
|
||||
frag_spv,
|
||||
false, // opaque — the CSC output IS the video
|
||||
)?;
|
||||
|
||||
@@ -184,6 +206,26 @@ impl CscPass {
|
||||
unsafe { device.update_descriptor_sets(&writes, &[]) };
|
||||
}
|
||||
|
||||
/// Planar variant of [`bind_planes`](Self::bind_planes): three single-component
|
||||
/// plane views in GENERAL layout (the pyrowave decode leaves them there; same
|
||||
/// fence-wait safety contract).
|
||||
#[cfg(feature = "pyrowave")]
|
||||
pub fn bind_planes_planar(&self, device: &ash::Device, planes: [vk::ImageView; 3]) {
|
||||
let infos = planes.map(|view| {
|
||||
[vk::DescriptorImageInfo::default()
|
||||
.image_view(view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)]
|
||||
});
|
||||
let writes = [0u32, 1, 2].map(|b| {
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(b)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&infos[b as usize])
|
||||
});
|
||||
unsafe { device.update_descriptor_sets(&writes, &[]) };
|
||||
}
|
||||
|
||||
pub fn destroy(&self, device: &ash::Device) {
|
||||
unsafe {
|
||||
device.destroy_pipeline(self.pipeline, None);
|
||||
|
||||
@@ -880,10 +880,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
|
||||
// --- Match-window (D2): debounced mode-follow + HUD/title refresh on a switch ----
|
||||
// HUD/title follow the live mode slot on ANY accepted switch — also when the
|
||||
// match-window follower is off (a switch can come from elsewhere, e.g. the
|
||||
// PUNKTFUNK_DEBUG_RECONFIGURE lever, or a host-side corrective rollback).
|
||||
if let Some(st) = stream.as_mut() {
|
||||
hud_mode_tick(st, &mut window, &opts.window_title);
|
||||
}
|
||||
// --- Match-window (D2): debounced mode-follow ----
|
||||
if let Some(persist) = opts.match_window.as_mut() {
|
||||
if let Some(st) = stream.as_mut() {
|
||||
resize_tick(st, &mut window, &opts.window_title, persist.as_mut());
|
||||
resize_tick(st, &mut window, persist.as_mut());
|
||||
}
|
||||
}
|
||||
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
|
||||
@@ -968,6 +974,28 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
image,
|
||||
} = f;
|
||||
let did_present = match image {
|
||||
// PyroWave planar frames: already on the presenter's device and
|
||||
// fence-complete — a present failure has no demote rung (nothing
|
||||
// else decodes the codec); only device loss ends the session.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
DecodedImage::PyroWave(f) => {
|
||||
st.hdr = false; // 8-bit SDR codec
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::PyroWave(f),
|
||||
overlay_frame.as_ref(),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if device_lost(&e) {
|
||||
return Err(e)
|
||||
.context("GPU device lost — the session cannot continue");
|
||||
}
|
||||
tracing::warn!(error = %format!("{e:#}"), "pyrowave present failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
DecodedImage::Cpu(c) => {
|
||||
st.hdr = c.color.is_pq();
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
@@ -1179,10 +1207,23 @@ fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window)
|
||||
);
|
||||
}
|
||||
|
||||
/// Match-window (D2) per-iteration tick: refresh the HUD line + window title when the
|
||||
/// live mode moves (an accepted switch's ack, or a corrective rollback), then fire the
|
||||
/// debounced `Reconfigure` once ~400 ms pass with no further resize events. The shared
|
||||
/// trigger discipline:
|
||||
/// Per-iteration HUD/title refresh: follow the live mode slot (updated by any accepted
|
||||
/// ack — a follower request, another trigger, or a host-side corrective rollback).
|
||||
fn hud_mode_tick(st: &mut StreamState, window: &mut sdl3::video::Window, title_base: &str) {
|
||||
let Some(c) = &st.connector else {
|
||||
return;
|
||||
};
|
||||
let m = c.mode();
|
||||
if st.shown_mode.is_some_and(|prev| prev != m) {
|
||||
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||
tracing::info!(mode = %st.mode_line, "stream mode switched");
|
||||
let _ = window.set_title(&format!("{title_base} · {}", st.mode_line));
|
||||
}
|
||||
st.shown_mode = Some(m);
|
||||
}
|
||||
|
||||
/// Match-window (D2) per-iteration tick: fire the debounced `Reconfigure` once ~400 ms
|
||||
/// pass with no further resize events. The shared trigger discipline:
|
||||
/// * physical pixels, even-floored, clamped ≥ 320×200; the current refresh is kept;
|
||||
/// * ≥ 1 s between requests (the accept ack round-trips in milliseconds — it precedes
|
||||
/// the host's rebuild — so the spacing also keeps at most ~one request outstanding);
|
||||
@@ -1192,21 +1233,12 @@ fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window)
|
||||
fn resize_tick(
|
||||
st: &mut StreamState,
|
||||
window: &mut sdl3::video::Window,
|
||||
title_base: &str,
|
||||
persist: &mut dyn FnMut(u32, u32),
|
||||
) {
|
||||
let Some(c) = &st.connector else {
|
||||
return; // not connected yet — the pending stamp survives until we are
|
||||
};
|
||||
// HUD/title follow the live mode slot (updated by any accepted ack).
|
||||
let m = c.mode();
|
||||
if st.shown_mode.is_some_and(|prev| prev != m) {
|
||||
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||
tracing::info!(mode = %st.mode_line, "stream mode switched");
|
||||
let _ = window.set_title(&format!("{title_base} · {}", st.mode_line));
|
||||
}
|
||||
st.shown_mode = Some(m);
|
||||
|
||||
match resize_decision(
|
||||
Instant::now(),
|
||||
&mut st.resize_pending,
|
||||
|
||||
@@ -40,6 +40,10 @@ pub enum FrameInput<'a> {
|
||||
/// D3D11VA hand-off — a shareable NT-handle texture to import (`d3d11.rs`).
|
||||
#[cfg(windows)]
|
||||
D3d11(pf_client_core::video::D3d11Frame),
|
||||
/// PyroWave planar output — three R8 plane views already on THIS device, decode
|
||||
/// fence-complete, GENERAL layout (`pf_client_core::video_pyrowave`).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
PyroWave(pf_client_core::video_pyrowave::PyroWavePlanarFrame),
|
||||
}
|
||||
|
||||
/// The dmabuf/CSC machinery, present only when the device carries the import extensions.
|
||||
@@ -321,6 +325,10 @@ pub struct Presenter {
|
||||
#[cfg(windows)]
|
||||
hw_win: Option<HwCtxWin>,
|
||||
csc: CscPass,
|
||||
/// The planar (3-plane) CSC variant for PyroWave frames; built only when the device
|
||||
/// passed the pyrowave probe.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
csc_planar: Option<CscPass>,
|
||||
/// FFmpeg Vulkan Video decode handles — `None` when the stack can't do it.
|
||||
video_export: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
/// The console-UI composite quad (§6.1's presenter half).
|
||||
@@ -497,9 +505,23 @@ impl Presenter {
|
||||
.push_next(&mut have_f12)
|
||||
.push_next(&mut have_f13);
|
||||
unsafe { instance.get_physical_device_features2(pdev, &mut have_f2) };
|
||||
// Copy the one base-features fact out NOW: `have_f2` mutably borrows the 11/12/13
|
||||
// structs through its pNext chain, so any later use of it would pin those borrows.
|
||||
let have_shader_int16 = have_f2.features.shader_int16;
|
||||
let features_ok = have_f11.sampler_ycbcr_conversion == vk::TRUE
|
||||
&& have_f12.timeline_semaphore == vk::TRUE
|
||||
&& have_f13.synchronization2 == vk::TRUE;
|
||||
// PyroWave decode (the wired-LAN wavelet codec, design/pyrowave-codec-plan.md §4.5):
|
||||
// plain Vulkan-1.3 compute on THIS device — no video extensions. Probed alongside so a
|
||||
// capable device gets the features enabled below and advertises the codec; anything
|
||||
// less simply never sets the CODEC_PYROWAVE bit.
|
||||
let pyrowave_ok = dev_is_13
|
||||
&& have_shader_int16 == vk::TRUE
|
||||
&& have_f12.storage_buffer8_bit_access == vk::TRUE
|
||||
&& have_f12.timeline_semaphore == vk::TRUE
|
||||
&& have_f13.subgroup_size_control == vk::TRUE
|
||||
&& have_f13.compute_full_subgroups == vk::TRUE
|
||||
&& have_f13.synchronization2 == vk::TRUE;
|
||||
|
||||
// The decode queue family + which codec operations it can run.
|
||||
let decode_family: Option<(u32, vk::VideoCodecOperationFlagsKHR)> = {
|
||||
@@ -575,13 +597,18 @@ impl Presenter {
|
||||
let mut en_f11 = vk::PhysicalDeviceVulkan11Features::default()
|
||||
.sampler_ycbcr_conversion(have_f11.sampler_ycbcr_conversion == vk::TRUE);
|
||||
let mut en_f12 = vk::PhysicalDeviceVulkan12Features::default()
|
||||
.timeline_semaphore(have_f12.timeline_semaphore == vk::TRUE);
|
||||
.timeline_semaphore(have_f12.timeline_semaphore == vk::TRUE)
|
||||
.storage_buffer8_bit_access(pyrowave_ok)
|
||||
.shader_float16(pyrowave_ok && have_f12.shader_float16 == vk::TRUE);
|
||||
let mut en_f13 = vk::PhysicalDeviceVulkan13Features::default()
|
||||
.synchronization2(have_f13.synchronization2 == vk::TRUE);
|
||||
.synchronization2(have_f13.synchronization2 == vk::TRUE)
|
||||
.subgroup_size_control(pyrowave_ok)
|
||||
.compute_full_subgroups(pyrowave_ok);
|
||||
let mut en_f2 = vk::PhysicalDeviceFeatures2::default()
|
||||
.push_next(&mut en_f11)
|
||||
.push_next(&mut en_f12)
|
||||
.push_next(&mut en_f13);
|
||||
en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE };
|
||||
|
||||
let priorities = [1.0f32];
|
||||
let mut queue_info = vec![vk::DeviceQueueCreateInfo::default()
|
||||
@@ -622,6 +649,13 @@ impl Presenter {
|
||||
ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device),
|
||||
});
|
||||
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
|
||||
// PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let csc_planar = if pyrowave_ok {
|
||||
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// The exported handle bundle: FFmpeg Vulkan Video handles when the device can
|
||||
// decode, AND (Windows) the D3D11-interop facts — so it's built whenever EITHER
|
||||
@@ -632,9 +666,9 @@ impl Presenter {
|
||||
// all funnel their queue calls through it — see the `queue_lock` field docs).
|
||||
let queue_lock = std::sync::Arc::new(pf_client_core::video::QueueLock::new());
|
||||
#[cfg(windows)]
|
||||
let export_worthy = video_ok || win_capable;
|
||||
let export_worthy = video_ok || win_capable || pyrowave_ok;
|
||||
#[cfg(not(windows))]
|
||||
let export_worthy = video_ok;
|
||||
let export_worthy = video_ok || pyrowave_ok;
|
||||
let video_export = if export_worthy {
|
||||
let qf_props = unsafe { instance.get_physical_device_queue_family_properties(pdev) };
|
||||
let mut device_extensions: Vec<CString> =
|
||||
@@ -678,6 +712,14 @@ impl Presenter {
|
||||
f_sampler_ycbcr: have_f11.sampler_ycbcr_conversion == vk::TRUE,
|
||||
f_timeline_semaphore: have_f12.timeline_semaphore == vk::TRUE,
|
||||
f_synchronization2: have_f13.synchronization2 == vk::TRUE,
|
||||
f_shader_int16: pyrowave_ok,
|
||||
f_storage_buffer8: pyrowave_ok,
|
||||
f_subgroup_size_control: pyrowave_ok,
|
||||
f_compute_full_subgroups: pyrowave_ok,
|
||||
f_shader_float16: pyrowave_ok && have_f12.shader_float16 == vk::TRUE,
|
||||
api_version: dev_props.api_version,
|
||||
queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(),
|
||||
pyrowave_decode: pyrowave_ok,
|
||||
video_decode: video_ok,
|
||||
#[cfg(windows)]
|
||||
d3d11_import: win_capable,
|
||||
@@ -742,6 +784,8 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
hw_win,
|
||||
csc,
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
csc_planar,
|
||||
video_export,
|
||||
overlay_pipe,
|
||||
retired_hw: None,
|
||||
@@ -1017,6 +1061,10 @@ impl Presenter {
|
||||
vk::Format::R8G8B8A8_UNORM
|
||||
};
|
||||
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(p) = &self.csc_planar {
|
||||
p.destroy(&self.device);
|
||||
}
|
||||
self.csc = CscPass::new(&self.device, self.video_format)?;
|
||||
if let Some(v) = self.video.take() {
|
||||
unsafe {
|
||||
@@ -1075,6 +1123,8 @@ impl Presenter {
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
#[cfg(windows)]
|
||||
FrameInput::D3d11(d) => Some(d.color.is_pq()),
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today
|
||||
};
|
||||
if let Some(pq) = frame_pq {
|
||||
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
|
||||
@@ -1103,6 +1153,8 @@ impl Presenter {
|
||||
#[cfg(windows)]
|
||||
let mut win_frame: Option<crate::d3d11::HwFrame> = None;
|
||||
let mut vk_frame: Option<(VkVideoFrame, [vk::ImageView; 2])> = None;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let mut pyro_frame: Option<pf_client_core::video_pyrowave::PyroWavePlanarFrame> = None;
|
||||
let cpu_frame = match input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(f) => Some(f),
|
||||
@@ -1129,6 +1181,11 @@ impl Presenter {
|
||||
vk_frame = Some((v, views));
|
||||
None
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
FrameInput::PyroWave(f) => {
|
||||
pyro_frame = Some(f);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// One frame in flight: the fence covers the command buffer, the staging buffer
|
||||
@@ -1183,6 +1240,22 @@ impl Presenter {
|
||||
}
|
||||
self.csc.bind_planes(&self.device, views[0], views[1]);
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(f) = &pyro_frame {
|
||||
if self
|
||||
.video
|
||||
.as_ref()
|
||||
.is_none_or(|v| v.width != f.width || v.height != f.height)
|
||||
{
|
||||
self.rebuild_video_image(f.width, f.height)?;
|
||||
tracing::info!(width = f.width, height = f.height, "video image (re)built");
|
||||
}
|
||||
let planar = self
|
||||
.csc_planar
|
||||
.as_ref()
|
||||
.context("PyroWave frame but the device failed the pyrowave probe")?;
|
||||
planar.bind_planes_planar(&self.device, f.views.map(vk::ImageView::from_raw));
|
||||
}
|
||||
if let Some(o) = overlay {
|
||||
// Point the composite at this overlay image (same fence-wait safety).
|
||||
let infos = [vk::DescriptorImageInfo::default()
|
||||
@@ -1325,6 +1398,18 @@ impl Presenter {
|
||||
vk_sync = Some(sync);
|
||||
}
|
||||
|
||||
// PyroWave frame: the planes are already on THIS device, decode
|
||||
// fence-complete and barriered to fragment sampling (GENERAL) by the
|
||||
// decoder — no acquire needed, just the planar CSC pass.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let (Some(f), Some(v)) = (&pyro_frame, &self.video) {
|
||||
let extent = vk::Extent2D {
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
};
|
||||
self.record_csc_planar(v.framebuffer, extent, f.color);
|
||||
}
|
||||
|
||||
// New frame: staging → video image (stride carried by buffer_row_length).
|
||||
if let (Some(f), Some(v), Some(s)) = (cpu_frame, &self.video, &self.staging) {
|
||||
barrier(
|
||||
@@ -1681,6 +1766,81 @@ impl Presenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`record_csc`] over the planar (PyroWave) pass — always 8-bit, no MSB packing.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
unsafe fn record_csc_planar(
|
||||
&self,
|
||||
framebuffer: vk::Framebuffer,
|
||||
extent: vk::Extent2D,
|
||||
color: pf_client_core::video::ColorDesc,
|
||||
) {
|
||||
// The planar pass exists whenever a PyroWave frame reached us (checked at bind).
|
||||
let Some(planar) = self.csc_planar.as_ref() else {
|
||||
return;
|
||||
};
|
||||
unsafe {
|
||||
self.device.cmd_begin_render_pass(
|
||||
self.cmd_buf,
|
||||
&vk::RenderPassBeginInfo::default()
|
||||
.render_pass(planar.render_pass)
|
||||
.framebuffer(framebuffer)
|
||||
.render_area(vk::Rect2D {
|
||||
offset: vk::Offset2D { x: 0, y: 0 },
|
||||
extent,
|
||||
}),
|
||||
vk::SubpassContents::INLINE,
|
||||
);
|
||||
self.device.cmd_bind_pipeline(
|
||||
self.cmd_buf,
|
||||
vk::PipelineBindPoint::GRAPHICS,
|
||||
planar.pipeline,
|
||||
);
|
||||
self.device.cmd_set_viewport(
|
||||
self.cmd_buf,
|
||||
0,
|
||||
&[vk::Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: extent.width as f32,
|
||||
height: extent.height as f32,
|
||||
min_depth: 0.0,
|
||||
max_depth: 1.0,
|
||||
}],
|
||||
);
|
||||
self.device.cmd_set_scissor(
|
||||
self.cmd_buf,
|
||||
0,
|
||||
&[vk::Rect2D {
|
||||
offset: vk::Offset2D { x: 0, y: 0 },
|
||||
extent,
|
||||
}],
|
||||
);
|
||||
self.device.cmd_bind_descriptor_sets(
|
||||
self.cmd_buf,
|
||||
vk::PipelineBindPoint::GRAPHICS,
|
||||
planar.pipeline_layout,
|
||||
0,
|
||||
&[planar.desc_set],
|
||||
&[],
|
||||
);
|
||||
let rows = csc_rows(color, 8, false);
|
||||
let mut pc = [0f32; 16];
|
||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||
pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path
|
||||
pc[13] = 0.0;
|
||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd_buf,
|
||||
planar.pipeline_layout,
|
||||
vk::ShaderStageFlags::FRAGMENT,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
self.device.cmd_draw(self.cmd_buf, 3, 1, 0, 0);
|
||||
self.device.cmd_end_render_pass(self.cmd_buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's
|
||||
/// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this).
|
||||
/// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6).
|
||||
@@ -1929,6 +2089,10 @@ impl Drop for Presenter {
|
||||
#[cfg(target_os = "linux")]
|
||||
self.hw.take();
|
||||
self.csc.destroy(&self.device);
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
if let Some(p) = &self.csc_planar {
|
||||
p.destroy(&self.device);
|
||||
}
|
||||
self.overlay_pipe.destroy(&self.device);
|
||||
for s in self.render_sems.drain(..) {
|
||||
self.device.destroy_semaphore(s, None);
|
||||
|
||||
@@ -584,7 +584,11 @@ pub struct PunktfunkHidOutput {
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkHidOutput {
|
||||
fn from_hid(h: &crate::quic::HidOutput) -> PunktfunkHidOutput {
|
||||
/// `None` for a [`HidOutput::HidRaw`](crate::quic::HidOutput) — a raw passthrough report
|
||||
/// (up to 64 bytes) doesn't fit this struct's 11-byte `effect` buffer, and no C-ABI embedder
|
||||
/// declares the as-is SC2 kind that would receive one; the pull site skips it rather than
|
||||
/// truncating it into an unreplayable stub.
|
||||
fn from_hid(h: &crate::quic::HidOutput) -> Option<PunktfunkHidOutput> {
|
||||
use crate::quic::HidOutput;
|
||||
let mut out = PunktfunkHidOutput {
|
||||
kind: 0,
|
||||
@@ -635,8 +639,9 @@ impl PunktfunkHidOutput {
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
out.effect_len = 6;
|
||||
}
|
||||
HidOutput::HidRaw { .. } => return None,
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,6 +900,16 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
|
||||
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
|
||||
/// hidraw writes (lizard mode, IMU enable, rumble/haptics) come back raw for the physical pad.
|
||||
/// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
|
||||
/// else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
|
||||
/// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface
|
||||
/// topology and four controller slots. Used by capture clients that own the physical Puck;
|
||||
/// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10;
|
||||
|
||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
@@ -928,6 +943,10 @@ pub const PUNKTFUNK_CODEC_H264: u8 = 0x01;
|
||||
pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
|
||||
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
||||
/// Codec bit: PyroWave — the opt-in wired-LAN intra-only wavelet codec. Never auto-selected:
|
||||
/// the host picks it ONLY when the client also passes it as `preferred_codec`
|
||||
/// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.)
|
||||
pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
@@ -938,6 +957,7 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
|
||||
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
||||
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
||||
assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
@@ -953,6 +973,10 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32);
|
||||
assert!(
|
||||
PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32
|
||||
);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
@@ -1332,13 +1356,132 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
unsafe {
|
||||
connect_ex_impl(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex7`], but additionally reports WHY a failed connect failed:
|
||||
/// `status_out` (nullable — null is exactly `ex7`) receives a [`PunktfunkStatus`] as `i32` —
|
||||
/// `Ok` on success, the mapped error otherwise, including the typed host-rejection block
|
||||
/// (`PUNKTFUNK_STATUS_REJECTED_NOT_ARMED` … `PUNKTFUNK_STATUS_REJECTED_BUSY`) decoded from the
|
||||
/// host's application close. That lets an embedder tell "denied in the console" / "nobody
|
||||
/// approved in time" / "host busy" / "versions don't match" apart from plain unreachability
|
||||
/// (`Io`/`Timeout`) — a NULL return alone can't say which.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex8(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
video_codecs: u8,
|
||||
preferred_codec: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
unsafe {
|
||||
connect_ex_impl(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
#[cfg(feature = "quic")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn connect_ex_impl(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
video_codecs: u8,
|
||||
preferred_codec: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let set_status = |s: crate::error::PunktfunkStatus| {
|
||||
if !status_out.is_null() {
|
||||
unsafe { *status_out = s as i32 };
|
||||
}
|
||||
};
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
set_status(crate::error::PunktfunkStatus::InvalidArg);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
let host = match unsafe { std::ffi::CStr::from_ptr(host) }.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
Err(_) => {
|
||||
set_status(crate::error::PunktfunkStatus::InvalidArg);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
};
|
||||
// A bad-UTF-8 launch id is non-fatal — treat it as "no game" rather than failing connect.
|
||||
let launch = match unsafe { opt_cstr(launch_id) } {
|
||||
@@ -1370,7 +1513,11 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
}) {
|
||||
(Ok(Some(c)), Ok(Some(k))) => Some((c.to_string(), k.to_string())),
|
||||
(Ok(None), Ok(None)) => None,
|
||||
_ => return std::ptr::null_mut(), // half an identity / bad UTF-8: fail closed
|
||||
_ => {
|
||||
// Half an identity / bad UTF-8: fail closed.
|
||||
set_status(crate::error::PunktfunkStatus::InvalidArg);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
};
|
||||
match crate::client::NativeClient::connect(
|
||||
host,
|
||||
@@ -1399,6 +1546,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
.copy_from_slice(&c.host_fingerprint);
|
||||
}
|
||||
}
|
||||
set_status(crate::error::PunktfunkStatus::Ok);
|
||||
Box::into_raw(Box::new(PunktfunkConnection {
|
||||
inner: c,
|
||||
last: std::sync::Mutex::new(None),
|
||||
@@ -1406,10 +1554,16 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||
}))
|
||||
}
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
Err(e) => {
|
||||
set_status(e.status());
|
||||
std::ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}));
|
||||
r.unwrap_or(std::ptr::null_mut())
|
||||
r.unwrap_or_else(|_| {
|
||||
set_status(crate::error::PunktfunkStatus::Panic);
|
||||
std::ptr::null_mut()
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a persistent client identity: a self-signed certificate + private key, both
|
||||
@@ -1888,10 +2042,15 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
.inner
|
||||
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(h) => {
|
||||
unsafe { *out = PunktfunkHidOutput::from_hid(&h) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
|
||||
Some(v) => {
|
||||
unsafe { *out = v };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
// A raw as-is passthrough report (no C representation) — report "nothing this
|
||||
// poll" and let the embedder's poll loop continue; see `from_hid`.
|
||||
None => PunktfunkStatus::NoFrame,
|
||||
},
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
@@ -2078,6 +2237,33 @@ pub unsafe extern "C" fn punktfunk_connection_codec(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the session's negotiated wire shard payload (the `Welcome`'s value, bytes). This is the
|
||||
/// parse-window size of a [`USER_FLAG_CHUNK_ALIGNED`] AU (PyroWave datagram-aligned mode,
|
||||
/// design/pyrowave-codec-plan.md §4.4): every `shard_payload`-sized window of the frame buffer
|
||||
/// starts a fresh self-delimiting chunk. Clients that decode PyroWave natively (the Apple Metal
|
||||
/// port) need it to walk those AUs; other codecs never need this.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_shard_payload(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if !out.is_null() {
|
||||
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u32`.
|
||||
unsafe { *out = u32::from(c.inner.shard_payload) };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
@@ -2507,6 +2693,63 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
})
|
||||
}
|
||||
|
||||
/// Report one decoded frame's decode-stage latency, in microseconds: the wall-clock elapsed from
|
||||
/// the access unit leaving [`punktfunk_connection_next_au`] to its decoded output becoming
|
||||
/// available (VideoToolbox/D3D11VA/… produced the frame). This feeds the "Automatic" bitrate
|
||||
/// controller's decode signal — the only one that sees the client's own decoder, so the rate is
|
||||
/// capped at the real decode limit instead of climbing to the network link ceiling and choking a
|
||||
/// slower hardware decoder (a fast LAN feeding a mobile-class decoder). Measure from the AU pull,
|
||||
/// NOT from the decoder-submit call, so decoder-input backpressure (the backlog) is included;
|
||||
/// exclude the presenter's vsync wait so a paced/capped frame rate doesn't read as decode latency.
|
||||
/// Cheap — the client may call it every frame; the controller ignores it unless armed (query
|
||||
/// [`punktfunk_connection_wants_decode_latency`] once to skip the measurement entirely when it's not).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
|
||||
c: *const PunktfunkConnection,
|
||||
us: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.report_decode_us(us);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether [`punktfunk_connection_report_decode_us`] is worth calling this session: writes 1 to
|
||||
/// `out` only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so a
|
||||
/// client can skip the per-frame decode-latency measurement entirely for explicit-bitrate and
|
||||
/// PyroWave sessions (where the signal is ignored). Constant for the session — query once. Writes 0
|
||||
/// on a NULL connection.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
|
||||
c: *const PunktfunkConnection,
|
||||
out: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
unsafe {
|
||||
if !out.is_null() {
|
||||
*out = c.inner.wants_decode_latency();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
/// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||
|
||||
@@ -53,6 +53,15 @@ const HEAVY_LOSS_PPM: u32 = 20_000;
|
||||
/// How far the window's mean one-way delay may sit above the rolling baseline before it counts
|
||||
/// as queue growth. 25 ms is far beyond jitter at any streamable frame rate.
|
||||
const OWD_RISE_US: i64 = 25_000;
|
||||
/// How far the window's mean *decode-stage* latency (client hand-off → decoder output, reported by
|
||||
/// the embedder) may sit above its rolling baseline before it counts as the decoder falling behind.
|
||||
/// This is the signal the network-side ones can't see: on a fast LAN a mobile HW decoder saturates
|
||||
/// long before the link does, backlogging frames INSIDE the decoder where loss/OWD never register —
|
||||
/// so without this the controller slow-starts straight to the link ceiling and parks there, choking
|
||||
/// the decoder. A rising decode latency ends the climb and (sustained) backs the rate off to the
|
||||
/// real decode limit. Local, low-noise signal (no network jitter), so a tighter threshold than OWD:
|
||||
/// 15 ms of standing decode queue is unambiguous backlog at any streamable frame rate.
|
||||
const DECODE_RISE_US: i64 = 15_000;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -76,6 +85,10 @@ pub(crate) struct BitrateController {
|
||||
probing: bool,
|
||||
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
||||
owd_means: VecDeque<i64>,
|
||||
/// Recent window mean decode-stage latencies (µs); the rolling min is the decoder's
|
||||
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
||||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||||
decode_means: VecDeque<i64>,
|
||||
bad_windows: u32,
|
||||
clean_windows: u32,
|
||||
last_change: Option<Instant>,
|
||||
@@ -95,6 +108,7 @@ impl BitrateController {
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
last_change: None,
|
||||
@@ -125,13 +139,16 @@ impl BitrateController {
|
||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
||||
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
||||
/// without a clock handshake), `flushed` = the pump's jump-to-live fired in the window.
|
||||
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent), `flushed` = the
|
||||
/// pump's jump-to-live fired in the window.
|
||||
pub(crate) fn on_window(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
dropped: u64,
|
||||
loss_ppm: u32,
|
||||
owd_mean_us: Option<i64>,
|
||||
decode_mean_us: Option<i64>,
|
||||
flushed: bool,
|
||||
) -> Option<u32> {
|
||||
if !self.enabled {
|
||||
@@ -161,11 +178,31 @@ impl BitrateController {
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// Decode-stage latency: same rolling-min-baseline treatment as OWD, but measuring the
|
||||
// CLIENT'S decoder rather than the link. A rise means the decoder is backlogging frames —
|
||||
// the bottleneck the network signals are blind to. Marking the window bad both ends slow
|
||||
// start (so the climb stops the moment decode latency lifts, instead of doubling on into
|
||||
// the link ceiling) and, sustained, drives the ×0.7 backoff down to the real decode limit.
|
||||
let decode_bad = match decode_mean_us {
|
||||
Some(mean) => {
|
||||
let bad = self
|
||||
.decode_means
|
||||
.iter()
|
||||
.min()
|
||||
.is_some_and(|&base| mean > base + DECODE_RISE_US);
|
||||
if self.decode_means.len() == BASELINE_WINDOWS {
|
||||
self.decode_means.pop_front();
|
||||
}
|
||||
self.decode_means.push_back(mean);
|
||||
bad
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush) or
|
||||
// loss far past any blip — one window is enough. Ordinary congestion (heavy-but-
|
||||
// recoverable loss, an OWD rise) still needs two consecutive windows.
|
||||
// recoverable loss, an OWD rise, a decode-latency 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;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -231,7 +268,7 @@ mod tests {
|
||||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||||
let mut out = None;
|
||||
for i in from..from + n {
|
||||
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), false);
|
||||
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, false);
|
||||
if out.is_some() {
|
||||
return out;
|
||||
}
|
||||
@@ -244,7 +281,10 @@ mod tests {
|
||||
// start 0 = explicit user bitrate or a host that didn't echo one → permanently off.
|
||||
let mut c = BitrateController::new(0);
|
||||
let now = Instant::now();
|
||||
assert_eq!(c.on_window(now, 5, 900_000, Some(500_000), true), None);
|
||||
assert_eq!(
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -252,17 +292,23 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||
assert_eq!(c.on_window(ticks(start, 0), 0, 25_000, None, false), None);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 25_000, None, None, false),
|
||||
None
|
||||
);
|
||||
// The second consecutive bad window backs off ×0.7.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 0, 25_000, None, false),
|
||||
c.on_window(ticks(start, 1), 0, 25_000, None, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||
assert_eq!(c.on_window(ticks(start, 6), 0, 25_000, None, false), None); // bad #1 again
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 0, 25_000, None, false),
|
||||
c.on_window(ticks(start, 6), 0, 25_000, None, None, false),
|
||||
None
|
||||
); // bad #1 again
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 0, 25_000, None, None, false),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -273,16 +319,19 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, 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));
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, 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),
|
||||
c.on_window(ticks(start, 0), 0, 80_000, None, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
@@ -292,14 +341,17 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||
// boundary (tick 2 = 1.5 s) it fires.
|
||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 2), 1, 0, None, false), Some(9_800));
|
||||
assert_eq!(c.on_window(ticks(start, 1), 1, 0, None, None, false), None);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, false),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -307,11 +359,14 @@ mod tests {
|
||||
let mut c = BitrateController::new(6_000);
|
||||
let start = Instant::now();
|
||||
// ×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, 0), 1, 0, None, None, false),
|
||||
Some(5_000)
|
||||
);
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 6), 1, 0, None, None, false), None);
|
||||
assert_eq!(c.on_window(ticks(start, 7), 1, 0, None, None, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -319,7 +374,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
@@ -342,7 +397,7 @@ mod tests {
|
||||
// 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) {
|
||||
if let Some(k) = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, false) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
}
|
||||
@@ -356,20 +411,20 @@ mod tests {
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), false),
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), None, 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),
|
||||
c.on_window(ticks(start, 2), 1, 0, Some(10_000), None, 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);
|
||||
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, false);
|
||||
if next.is_some() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
break;
|
||||
@@ -382,7 +437,7 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(c.on_window(Instant::now(), 0, 0, None, 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);
|
||||
@@ -395,21 +450,72 @@ mod tests {
|
||||
// Establish a ~10 ms baseline over a few clean windows.
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), false),
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(50_000), false),
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(50_000), None, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 5), 0, 0, Some(52_000), false),
|
||||
c.on_window(ticks(start, 5), 0, 0, Some(52_000), None, false),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_latency_rise_alone_is_a_congestion_signal() {
|
||||
// The link is pristine (zero loss, flat OWD) but the client's decoder is falling behind —
|
||||
// the LAN-vs-mobile-decoder case. Only the decode signal can catch it.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
// A ~8 ms decode baseline over a few clean windows.
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), Some(8_000), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
// Decode latency climbs 30 ms above baseline with ZERO loss and flat OWD: the decoder is
|
||||
// backlogging. Two windows → back off ×0.7, exactly like an OWD rise.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(10_000), Some(38_000), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 5), 0, 0, Some(10_000), Some(40_000), false),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_latency_caps_the_slow_start_climb() {
|
||||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
// First clean window (decoder fine at 20 Mbps) → slow start doubles to 40.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), Some(8_000), false),
|
||||
Some(40_000)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// At 40 Mbps the decoder starts backing up (30 ms over baseline): the window is bad, so the
|
||||
// climb stops here instead of doubling on toward the 300 Mbps link ceiling…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 0, 0, Some(10_000), Some(38_000), false),
|
||||
None
|
||||
);
|
||||
// …and a second backed-up window backs the rate off, settling at the decode limit rather
|
||||
// than choking the decoder at the link ceiling (the reported bug).
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(10_000), Some(40_000), false),
|
||||
Some(28_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -418,7 +524,9 @@ mod tests {
|
||||
let mut i = 0;
|
||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||
while i < 60 {
|
||||
if c.on_window(ticks(start, i), 1, 0, None, false).is_some() {
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, false)
|
||||
.is_some()
|
||||
{
|
||||
sent += 1;
|
||||
}
|
||||
i += 1;
|
||||
|
||||
+253
-117
@@ -71,6 +71,8 @@ enum CtrlRequest {
|
||||
#[derive(Clone, Copy)]
|
||||
struct Negotiated {
|
||||
mode: Mode,
|
||||
/// Wire shard payload — the chunk-aligned parse window (plan §4.4).
|
||||
shard_payload: u16,
|
||||
compositor: CompositorPref,
|
||||
gamepad: GamepadPref,
|
||||
/// SHA-256 of the certificate the host actually presented (TOFU callers persist this).
|
||||
@@ -232,6 +234,21 @@ const MIC_QUEUE: usize = 64;
|
||||
/// the control task is wedged, which callers treat as a closed session.
|
||||
const CTRL_QUEUE: usize = 32;
|
||||
|
||||
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
|
||||
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
|
||||
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
|
||||
/// window mean once per report window to feed [`crate::abr::BitrateController::on_window`]. This is
|
||||
/// the only signal that sees the CLIENT'S decoder: on a fast LAN a mobile HW decoder saturates long
|
||||
/// before the link, backlogging frames inside the decoder where loss/OWD never register. Sum+count
|
||||
/// (not a running mean) so the pump takes an unweighted window mean and resets. Always accumulated —
|
||||
/// the controller ignores it when Automatic is off, and the pump drains it every window regardless,
|
||||
/// so it stays bounded (a full window at 240 fps is ~180 samples).
|
||||
#[derive(Default)]
|
||||
struct DecodeLatAcc {
|
||||
sum_us: u64,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||
@@ -495,6 +512,14 @@ pub struct NativeClient {
|
||||
/// the pump's first no-op clock flush). Shared with the pump and, via
|
||||
/// [`clock_offset_shared`](Self::clock_offset_shared), with embedder latency-math threads.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// Decode-stage latency samples from the embedder ([`report_decode_us`](Self::report_decode_us)),
|
||||
/// drained per window by the data-plane pump to feed the adaptive-bitrate controller's decode
|
||||
/// signal. Shared with the pump; see [`DecodeLatAcc`].
|
||||
decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
/// Whether the adaptive-bitrate controller is armed for this session (Automatic bitrate and not
|
||||
/// a rate-pinned PyroWave stream) — exposed via [`wants_decode_latency`](Self::wants_decode_latency)
|
||||
/// so an embedder skips the per-frame decode measurement when the controller wouldn't use it.
|
||||
wants_decode: bool,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
/// The currently active session mode (the Welcome's, then updated by every accepted
|
||||
/// [`NativeClient::request_mode`]).
|
||||
@@ -513,6 +538,9 @@ pub struct NativeClient {
|
||||
/// requested rate clamped to the host's range, or its default if we requested `0`. `0` = an
|
||||
/// older host that didn't report it.
|
||||
pub resolved_bitrate_kbps: u32,
|
||||
/// The session's wire shard payload (bytes of AU per datagram) — the parse-window size
|
||||
/// for chunk-aligned AUs ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`], plan §4.4).
|
||||
pub shard_payload: u16,
|
||||
/// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local
|
||||
/// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making
|
||||
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
|
||||
@@ -675,6 +703,7 @@ impl NativeClient {
|
||||
let fec_recovered = Arc::new(AtomicU64::new(0));
|
||||
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||
let clock_offset = Arc::new(AtomicI64::new(0));
|
||||
let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default()));
|
||||
|
||||
let host = host.to_string();
|
||||
let frame_chan_w = frame_chan.clone();
|
||||
@@ -686,6 +715,7 @@ impl NativeClient {
|
||||
let fec_recovered_w = fec_recovered.clone();
|
||||
let hot_tids_w = hot_tids.clone();
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -740,6 +770,7 @@ impl NativeClient {
|
||||
fec_recovered: fec_recovered_w,
|
||||
hot_tids: hot_tids_w,
|
||||
clock_offset: clock_offset_w,
|
||||
decode_lat: decode_lat_w,
|
||||
}));
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
@@ -773,11 +804,16 @@ impl NativeClient {
|
||||
rfi: Mutex::new(RfiRecovery::default()),
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
// The controller arms exactly when the pump does (see `abr::BitrateController::new`
|
||||
// below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
|
||||
wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
|
||||
mode: mode_slot,
|
||||
host_fingerprint: negotiated.host_fingerprint,
|
||||
resolved_compositor: negotiated.compositor,
|
||||
resolved_gamepad: negotiated.gamepad,
|
||||
resolved_bitrate_kbps: negotiated.bitrate_kbps,
|
||||
shard_payload: negotiated.shard_payload,
|
||||
clock_offset_ns: negotiated.clock_offset_ns,
|
||||
bit_depth: negotiated.bit_depth,
|
||||
color: negotiated.color,
|
||||
@@ -862,7 +898,16 @@ impl NativeClient {
|
||||
.await
|
||||
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let host_fp = observed.lock().unwrap().ok_or(PunktfunkError::Crypto)?;
|
||||
let outcome = exchange(conn.clone(), host_fp).await;
|
||||
let outcome = match exchange(conn.clone(), host_fp).await {
|
||||
// A typed application close from the host (pairing not armed / armed for a
|
||||
// different device / rate-limited / version mismatch) beats the generic
|
||||
// transport error the aborted exchange produced — it is the actual answer.
|
||||
Err(e) => Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
}),
|
||||
ok => ok,
|
||||
};
|
||||
// Always tell the host we're done so it never blocks at its read — code 0 on
|
||||
// success, 1 on a refused/aborted ceremony.
|
||||
let code: u32 = if outcome.is_ok() { 0 } else { 1 };
|
||||
@@ -1075,6 +1120,30 @@ impl NativeClient {
|
||||
self.clock_offset.clone()
|
||||
}
|
||||
|
||||
/// Report one decoded frame's decode-stage latency, in microseconds: the wall-clock elapsed from
|
||||
/// the access unit leaving [`next_frame`](Self::next_frame) to its decoded output becoming
|
||||
/// available (dequeued from the decoder). This feeds the "Automatic" bitrate controller's decode
|
||||
/// signal — the only one that sees the client's own decoder, so the rate can be capped at the
|
||||
/// real decode limit instead of climbing to the network link ceiling and choking a slower HW
|
||||
/// decoder (the LAN-vs-mobile-decoder case). Measure from the AU handoff, NOT from the codec-queue
|
||||
/// call, so decoder-input backpressure (the backlog) is included; exclude the presenter's vsync
|
||||
/// wait so a paced/capped frame rate doesn't read as decode latency. Cheap and lock-brief — the
|
||||
/// embedder may call it every frame unconditionally; the controller ignores it when Automatic is
|
||||
/// off and the pump drains it every window regardless, so the accumulator stays bounded.
|
||||
pub fn report_decode_us(&self, us: u32) {
|
||||
let mut acc = self.decode_lat.lock().unwrap();
|
||||
acc.sum_us += us as u64;
|
||||
acc.count += 1;
|
||||
}
|
||||
|
||||
/// Whether [`report_decode_us`](Self::report_decode_us) is worth calling this session: `true`
|
||||
/// only when the adaptive-bitrate controller is armed (Automatic bitrate, non-PyroWave), so an
|
||||
/// embedder can skip the per-frame decode-latency measurement entirely for explicit-bitrate and
|
||||
/// PyroWave sessions (where the signal is ignored). Constant for the session — check once.
|
||||
pub fn wants_decode_latency(&self) -> bool {
|
||||
self.wants_decode
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||
@@ -1351,10 +1420,25 @@ struct WorkerArgs {
|
||||
/// The live clock offset (see [`NativeClient::clock_offset`]): the worker seeds it with the
|
||||
/// connect-time estimate; the control task's mid-stream re-syncs update it.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// Decode-stage latency samples from the embedder (see [`NativeClient::decode_lat`]): the pump
|
||||
/// drains a window mean into the adaptive-bitrate controller's decode signal.
|
||||
decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||
/// data-plane pump.
|
||||
/// The host's stated rejection, if this connection was closed with a typed application code
|
||||
/// (see [`crate::reject`]) — `None` for local errors, bare/legacy closes (including our own
|
||||
/// `LocallyClosed`), and transport failures, which keep their original error.
|
||||
fn reject_from_close(conn: &quinn::Connection) -> Option<crate::reject::RejectReason> {
|
||||
match conn.close_reason()? {
|
||||
quinn::ConnectionError::ApplicationClosed(ac) => u32::try_from(u64::from(ac.error_code))
|
||||
.ok()
|
||||
.and_then(crate::reject::RejectReason::from_close_code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn worker_main(args: WorkerArgs) {
|
||||
let WorkerArgs {
|
||||
host,
|
||||
@@ -1391,6 +1475,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fec_recovered,
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
} = args;
|
||||
let setup = async {
|
||||
let remote: std::net::SocketAddr = join_host_port(&host, port)
|
||||
@@ -1417,124 +1502,147 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
})?;
|
||||
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
// The rest of the handshake runs in an inner future so a failure can consult
|
||||
// `conn.close_reason()`: a host that turned us away with a typed application close
|
||||
// (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
|
||||
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
|
||||
// read produces — the difference between "not accepted" and the actual cause.
|
||||
let handshake = async {
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: crate::WIRE_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||
name: None,
|
||||
// Library id to launch this session, if the embedder asked for one.
|
||||
launch: launch.clone(),
|
||||
// The embedder's decode/present caps (e.g. the Windows client advertises
|
||||
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
|
||||
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
|
||||
// bit only asks the host for observability datagrams (never changes the encode).
|
||||
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||
// video frame indexes.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||
// resolves the emitted codec from these and reports it in `Welcome::codec`.
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
||||
// tone-map to the client's real panel). `None` = unknown/SDR.
|
||||
display_hdr,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
|
||||
if welcome.compositor != CompositorPref::Auto {
|
||||
tracing::info!(
|
||||
compositor = welcome.compositor.as_str(),
|
||||
"host resolved compositor"
|
||||
);
|
||||
}
|
||||
if welcome.gamepad != GamepadPref::Auto {
|
||||
tracing::info!(
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
"host resolved gamepad backend"
|
||||
);
|
||||
}
|
||||
|
||||
// Reserve our data-plane port, then start the host.
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let udp_port = probe.local_addr()?.port();
|
||||
drop(probe);
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Start {
|
||||
client_udp_port: udp_port,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Wall-clock skew handshake on the control stream (before the session's control task takes
|
||||
// it): align our clock to the host's so the embedder can express receive/present instants in
|
||||
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
||||
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
||||
let (clock_offset_ns, clock_rtt_ns) =
|
||||
match crate::quic::clock_sync(&mut send, &mut recv).await {
|
||||
Some(skew) => {
|
||||
tracing::info!(
|
||||
offset_ns = skew.offset_ns,
|
||||
rtt_us = skew.rtt_ns / 1000,
|
||||
rounds = skew.rounds,
|
||||
"clock skew estimated (host-client)"
|
||||
);
|
||||
(skew.offset_ns, Some(skew.rtt_ns))
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: crate::WIRE_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||
name: None,
|
||||
// Library id to launch this session, if the embedder asked for one.
|
||||
launch: launch.clone(),
|
||||
// The embedder's decode/present caps (e.g. the Windows client advertises
|
||||
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
|
||||
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
|
||||
// bit only asks the host for observability datagrams (never changes the encode).
|
||||
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||
// video frame indexes.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||
// resolves the emitted codec from these and reports it in `Welcome::codec`.
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
||||
// tone-map to the client's real panel). `None` = unknown/SDR.
|
||||
display_hdr,
|
||||
}
|
||||
None => (0, None),
|
||||
};
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
|
||||
if welcome.compositor != CompositorPref::Auto {
|
||||
tracing::info!(
|
||||
compositor = welcome.compositor.as_str(),
|
||||
"host resolved compositor"
|
||||
);
|
||||
}
|
||||
if welcome.gamepad != GamepadPref::Auto {
|
||||
tracing::info!(
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
"host resolved gamepad backend"
|
||||
);
|
||||
}
|
||||
|
||||
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
||||
let transport =
|
||||
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
|
||||
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
|
||||
// to open the path first). Stops with the session via the shared shutdown flag.
|
||||
if let Ok(sock) = transport.try_clone_socket() {
|
||||
crate::transport::spawn_data_punch(sock, shutdown.clone());
|
||||
// Reserve our data-plane port, then start the host.
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let udp_port = probe.local_addr()?.port();
|
||||
drop(probe);
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Start {
|
||||
client_udp_port: udp_port,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Wall-clock skew handshake on the control stream (before the session's control task takes
|
||||
// it): align our clock to the host's so the embedder can express receive/present instants in
|
||||
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
||||
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
||||
let (clock_offset_ns, clock_rtt_ns) =
|
||||
match crate::quic::clock_sync(&mut send, &mut recv).await {
|
||||
Some(skew) => {
|
||||
tracing::info!(
|
||||
offset_ns = skew.offset_ns,
|
||||
rtt_us = skew.rtt_ns / 1000,
|
||||
rounds = skew.rounds,
|
||||
"clock skew estimated (host-client)"
|
||||
);
|
||||
(skew.offset_ns, Some(skew.rtt_ns))
|
||||
}
|
||||
None => (0, None),
|
||||
};
|
||||
|
||||
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
||||
let transport =
|
||||
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
|
||||
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
|
||||
// to open the path first). Stops with the session via the shared shutdown flag.
|
||||
if let Ok(sock) = transport.try_clone_socket() {
|
||||
crate::transport::spawn_data_punch(sock, shutdown.clone());
|
||||
}
|
||||
let mut session =
|
||||
Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||
// PyroWave sessions opt into partial delivery (plan §4.4): an aged-out lossy
|
||||
// frame arrives as blocks-with-holes instead of vanishing — the all-intra codec
|
||||
// renders it as one frame of localized blur, strictly better than a freeze.
|
||||
if welcome.codec == crate::quic::CODEC_PYROWAVE {
|
||||
session.set_deliver_partial_frames(true);
|
||||
}
|
||||
Ok::<_, PunktfunkError>((
|
||||
session,
|
||||
send,
|
||||
recv,
|
||||
Negotiated {
|
||||
mode: welcome.mode,
|
||||
compositor: welcome.compositor,
|
||||
gamepad: welcome.gamepad,
|
||||
host_fingerprint: fingerprint,
|
||||
bitrate_kbps: welcome.bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
clock_rtt_ns,
|
||||
bit_depth: welcome.bit_depth,
|
||||
color: welcome.color,
|
||||
chroma_format: welcome.chroma_format,
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
};
|
||||
match handshake.await {
|
||||
Ok((session, send, recv, negotiated, host_caps)) => {
|
||||
Ok((conn, session, send, recv, negotiated, host_caps))
|
||||
}
|
||||
Err(e) => Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
}),
|
||||
}
|
||||
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||
Ok::<_, PunktfunkError>((
|
||||
conn,
|
||||
session,
|
||||
send,
|
||||
recv,
|
||||
Negotiated {
|
||||
mode: welcome.mode,
|
||||
compositor: welcome.compositor,
|
||||
gamepad: welcome.gamepad,
|
||||
host_fingerprint: fingerprint,
|
||||
bitrate_kbps: welcome.bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
clock_rtt_ns,
|
||||
bit_depth: welcome.bit_depth,
|
||||
color: welcome.color,
|
||||
chroma_format: welcome.chroma_format,
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
};
|
||||
|
||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
|
||||
@@ -1548,6 +1656,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||
let negotiated_codec = negotiated.codec;
|
||||
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||
@@ -1926,6 +2035,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let pump_hot_tids = hot_tids.clone();
|
||||
let pump_clock_offset = clock_offset.clone();
|
||||
let pump_clock_gen = clock_gen.clone();
|
||||
let pump_decode_lat = decode_lat.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||
@@ -1948,7 +2058,11 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// echoes 0 → controller stays permanently off). Fed once per report window with the same
|
||||
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay and
|
||||
// whether a jump-to-live flush fired.
|
||||
let mut abr = BitrateController::new(if bitrate_kbps == 0 {
|
||||
// PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
|
||||
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
|
||||
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
|
||||
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
|
||||
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
|
||||
resolved_bitrate_kbps
|
||||
} else {
|
||||
0
|
||||
@@ -1965,6 +2079,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
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
|
||||
&& !rate_pinned
|
||||
&& resolved_bitrate_kbps > 0
|
||||
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
|
||||
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
|
||||
@@ -2106,14 +2221,34 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let owd_mean_us =
|
||||
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
|
||||
(owd_sum_ns, owd_frames) = (0, 0);
|
||||
// Drain the embedder's decode-latency window (always, so it stays bounded even when
|
||||
// the controller is disabled) → the mean feeds the decode signal; `None` when the
|
||||
// embedder reported nothing this window (old embedder / no decoded frames).
|
||||
let decode_mean_us = {
|
||||
let mut acc = pump_decode_lat.lock().unwrap();
|
||||
let (sum, count) = (acc.sum_us, acc.count);
|
||||
*acc = DecodeLatAcc::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
if let Some(kbps) = abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
flush_in_window,
|
||||
) {
|
||||
tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target");
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
tracing::info!(
|
||||
kbps,
|
||||
loss_ppm,
|
||||
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||
flushed = flush_in_window,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||
}
|
||||
flush_in_window = false;
|
||||
@@ -2448,6 +2583,7 @@ mod frame_channel_tests {
|
||||
frame_index: i,
|
||||
pts_ns: i as u64,
|
||||
flags: 0,
|
||||
complete: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,9 @@ impl CompositorPref {
|
||||
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||
/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older
|
||||
/// peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -172,11 +173,24 @@ pub enum GamepadPref {
|
||||
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
|
||||
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
|
||||
SwitchPro,
|
||||
/// New Steam Controller (2026, Valve "Ibex"/SDL "Triton", wired `28DE:1302`) passed through
|
||||
/// AS-IS: the host presents a virtual SC2 with the real identity and mirrors the client's raw
|
||||
/// Triton input reports ([`RichInput::HidReport`](crate::quic::RichInput)); Steam on the host
|
||||
/// drives it over hidraw exactly like the physical pad (its feature/output writes — lizard
|
||||
/// mode, IMU enable, rumble/haptics — come back raw on the HID-output plane and land on the
|
||||
/// real controller). No kernel driver binds the PID (mainline `hid-steam` stops at the Deck),
|
||||
/// so Steam Input is the consumer. Needs Linux UHID.
|
||||
SteamController2,
|
||||
/// Steam Controller Puck dongle (`28DE:1304`) carrying a captured SC2. The host presents the
|
||||
/// native seven-interface Puck topology (CDC pair, four controller slots, management HID)
|
||||
/// rather than relabelling its reports as a wired `1302`.
|
||||
SteamController2Puck,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`.
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||
/// `9 = SteamController2`, `10 = SteamController2Puck`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -188,6 +202,8 @@ impl GamepadPref {
|
||||
GamepadPref::SteamDeck => 6,
|
||||
GamepadPref::DualSenseEdge => 7,
|
||||
GamepadPref::SwitchPro => 8,
|
||||
GamepadPref::SteamController2 => 9,
|
||||
GamepadPref::SteamController2Puck => 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +219,8 @@ impl GamepadPref {
|
||||
6 => GamepadPref::SteamDeck,
|
||||
7 => GamepadPref::DualSenseEdge,
|
||||
8 => GamepadPref::SwitchPro,
|
||||
9 => GamepadPref::SteamController2,
|
||||
10 => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -224,12 +242,19 @@ impl GamepadPref {
|
||||
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
|
||||
GamepadPref::SwitchPro
|
||||
}
|
||||
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
|
||||
GamepadPref::SteamController2
|
||||
}
|
||||
"steamcontroller2puck" | "steam-controller-2-puck" | "sc2puck" | "ibexpuck" => {
|
||||
GamepadPref::SteamController2Puck
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`).
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
|
||||
/// `"steamcontroller2"`, `"steamcontroller2puck"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -241,6 +266,8 @@ impl GamepadPref {
|
||||
GamepadPref::SteamDeck => "steamdeck",
|
||||
GamepadPref::DualSenseEdge => "dualsenseedge",
|
||||
GamepadPref::SwitchPro => "switchpro",
|
||||
GamepadPref::SteamController2 => "steamcontroller2",
|
||||
GamepadPref::SteamController2Puck => "steamcontroller2puck",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ pub enum PunktfunkError {
|
||||
Timeout,
|
||||
#[error("session closed")]
|
||||
Closed,
|
||||
/// The host deliberately turned this connection away and said why (a typed QUIC application
|
||||
/// close, [`crate::reject::RejectReason`]) — distinct from transport trouble ([`Self::Io`] /
|
||||
/// [`Self::Timeout`]) and from a failed PIN proof ([`Self::Crypto`]) so UIs can render the
|
||||
/// real cause instead of a generic "not accepted".
|
||||
#[error("rejected by host: {0}")]
|
||||
Rejected(crate::reject::RejectReason),
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, PunktfunkError>;
|
||||
@@ -43,6 +49,18 @@ pub enum PunktfunkStatus {
|
||||
NullPointer = -8,
|
||||
Timeout = -9,
|
||||
Closed = -10,
|
||||
// -11..-19 reserved for future generic errors. The -20 block mirrors
|
||||
// `crate::reject::RejectReason` one-to-one so FFI callers (Swift, JNI) can
|
||||
// render the host's actual rejection reason.
|
||||
RejectedNotArmed = -20,
|
||||
RejectedBoundOther = -21,
|
||||
RejectedRateLimited = -22,
|
||||
RejectedIdentityRequired = -23,
|
||||
RejectedDenied = -24,
|
||||
RejectedApprovalTimeout = -25,
|
||||
RejectedSuperseded = -26,
|
||||
RejectedWireVersion = -27,
|
||||
RejectedBusy = -28,
|
||||
Panic = -99,
|
||||
}
|
||||
|
||||
@@ -59,6 +77,20 @@ impl PunktfunkError {
|
||||
PunktfunkError::Io(_) => PunktfunkStatus::Io,
|
||||
PunktfunkError::Timeout => PunktfunkStatus::Timeout,
|
||||
PunktfunkError::Closed => PunktfunkStatus::Closed,
|
||||
PunktfunkError::Rejected(r) => {
|
||||
use crate::reject::RejectReason as R;
|
||||
match r {
|
||||
R::PairingNotArmed => PunktfunkStatus::RejectedNotArmed,
|
||||
R::PairingBoundToOtherDevice => PunktfunkStatus::RejectedBoundOther,
|
||||
R::PairingRateLimited => PunktfunkStatus::RejectedRateLimited,
|
||||
R::IdentityRequired => PunktfunkStatus::RejectedIdentityRequired,
|
||||
R::Denied => PunktfunkStatus::RejectedDenied,
|
||||
R::ApprovalTimeout => PunktfunkStatus::RejectedApprovalTimeout,
|
||||
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
||||
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
||||
R::Busy => PunktfunkStatus::RejectedBusy,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod packet;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod quic;
|
||||
pub mod reanchor;
|
||||
pub mod reject;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
pub mod transport;
|
||||
@@ -65,7 +66,12 @@ pub use stats::Stats;
|
||||
/// v6: added the `punktfunk_reanchor_gate_*` surface (post-loss freeze-until-reanchor gate for the
|
||||
/// Swift client; Rust embedders use [`reanchor::ReanchorGate`] directly). Additive, client-local —
|
||||
/// no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 6;
|
||||
/// v7: added `punktfunk_connect_ex8` (`status_out` — typed connect-failure reporting, including
|
||||
/// the host-rejection block `PUNKTFUNK_STATUS_REJECTED_*` decoded from the host's QUIC
|
||||
/// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are
|
||||
/// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is
|
||||
/// unchanged.
|
||||
pub const ABI_VERSION: u32 = 7;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -54,6 +54,14 @@ pub const USER_FLAG_RECOVERY_POINT: u32 = 0x10;
|
||||
/// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
|
||||
pub const USER_FLAG_RECOVERY_ANCHOR: u32 = 0x20;
|
||||
|
||||
/// `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every
|
||||
/// `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the
|
||||
/// window with zeros (PyroWave datagram-aligned mode, design/pyrowave-codec-plan.md §4.4). Two
|
||||
/// consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer
|
||||
/// AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a
|
||||
/// COMPLETE frame must be consumed window-by-window (the padding is not part of the stream).
|
||||
pub const USER_FLAG_CHUNK_ALIGNED: u32 = 0x40;
|
||||
|
||||
/// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
|
||||
/// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
|
||||
/// ends. RFI can only re-reference history the encoder still holds — NVENC keeps a 5-frame DPB,
|
||||
@@ -89,6 +97,13 @@ const LOSS_WINDOW_NS: u64 = 120_000_000;
|
||||
/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps.
|
||||
const HARD_LOSS_WINDOW: u32 = 64;
|
||||
|
||||
/// The much tighter fuse for PARTIAL-deliverable frames (chunk-aligned AUs with a consumer
|
||||
/// that opted in): once anything newer exists and this much capture time passed, the frame
|
||||
/// is delivered as-is — its stragglers can only make it less late, and each frame is
|
||||
/// independently decodable, so waiting the full loss window (120 ms) would inject ancient
|
||||
/// frames into a live stream. ~2 frame periods at 60 fps rides out normal reorder.
|
||||
const PARTIAL_WINDOW_NS: u64 = 30_000_000;
|
||||
|
||||
/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices
|
||||
/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an
|
||||
/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the
|
||||
@@ -451,6 +466,13 @@ const RECOVERY_POOL_MAX: usize = 512;
|
||||
/// Client-side only.
|
||||
pub struct Reassembler {
|
||||
limits: ReassemblerLimits,
|
||||
/// Deliver aged-out incomplete frames whose AUs are [`USER_FLAG_CHUNK_ALIGNED`] instead of
|
||||
/// silently dropping them (client opt-in — the PyroWave decode path): the frame buffer is
|
||||
/// already the right shape (received shards at their final offsets, zeros elsewhere).
|
||||
/// They still count into `frames_dropped` — a partial IS lost data for the loss reports.
|
||||
deliver_partial: bool,
|
||||
/// The newest such partial awaiting pickup (newest-wins: partials are a lossy byproduct).
|
||||
pending_partial: Option<Frame>,
|
||||
/// The video stream's window — its aged-out incomplete frames count into `frames_dropped`
|
||||
/// (the client's loss-recovery trigger).
|
||||
video: ReassemblyWindow,
|
||||
@@ -472,6 +494,8 @@ impl Reassembler {
|
||||
pub fn new(limits: ReassemblerLimits) -> Self {
|
||||
Reassembler {
|
||||
limits,
|
||||
deliver_partial: false,
|
||||
pending_partial: None,
|
||||
video: ReassemblyWindow::default(),
|
||||
probe: ReassemblyWindow::default(),
|
||||
recovery_pool: Vec::new(),
|
||||
@@ -479,6 +503,19 @@ impl Reassembler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt into partial delivery of chunk-aligned frames (see [`Reassembler::deliver_partial`]).
|
||||
pub fn set_deliver_partial(&mut self, on: bool) {
|
||||
self.deliver_partial = on;
|
||||
if !on {
|
||||
self.pending_partial = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the newest aged-out partial frame, if one is pending (see `set_deliver_partial`).
|
||||
pub fn take_partial(&mut self) -> Option<Frame> {
|
||||
self.pending_partial.take()
|
||||
}
|
||||
|
||||
/// Ingest one (already-decrypted) packet. Returns the access unit when its last
|
||||
/// block completes, otherwise `None`.
|
||||
pub fn push(
|
||||
@@ -507,6 +544,8 @@ impl Reassembler {
|
||||
// in-flight budget are all touched while a frame entry is mutably borrowed.
|
||||
let Reassembler {
|
||||
limits,
|
||||
deliver_partial,
|
||||
pending_partial,
|
||||
video,
|
||||
probe,
|
||||
recovery_pool,
|
||||
@@ -579,6 +618,7 @@ impl Reassembler {
|
||||
recovery_pool,
|
||||
in_flight_bytes,
|
||||
lim.max_data_shards,
|
||||
(*deliver_partial && !is_probe).then_some(pending_partial),
|
||||
);
|
||||
|
||||
// Drop shards for frames already terminated (emitted — e.g. the recovery shards of a
|
||||
@@ -756,6 +796,7 @@ impl Reassembler {
|
||||
frame_index: hdr.frame_index,
|
||||
pts_ns: done.pts_ns,
|
||||
flags: done.user_flags,
|
||||
complete: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
@@ -809,6 +850,8 @@ impl ReassemblyWindow {
|
||||
recovery_pool: &mut Vec<Vec<u8>>,
|
||||
in_flight_bytes: &mut usize,
|
||||
max_data_shards: usize,
|
||||
// `Some(sink)` = deliver aged-out CHUNK_ALIGNED frames instead of only dropping them.
|
||||
mut partial_sink: Option<&mut Option<Frame>>,
|
||||
) {
|
||||
let (newest, newest_pts) = match self.newest_frame {
|
||||
// `frame_index` is newer iff it's within the forward half of the index space.
|
||||
@@ -819,9 +862,17 @@ impl ReassemblyWindow {
|
||||
|
||||
let before = self.frames.len();
|
||||
let completed = &mut self.completed;
|
||||
let partial_on = partial_sink.is_some();
|
||||
self.frames.retain(|&idx, f| {
|
||||
// Partial-deliverable frames age out on the TIGHT fuse (see PARTIAL_WINDOW_NS);
|
||||
// everything else keeps the full loss window.
|
||||
let window_ns = if partial_on && f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 {
|
||||
PARTIAL_WINDOW_NS
|
||||
} else {
|
||||
LOSS_WINDOW_NS
|
||||
};
|
||||
let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW
|
||||
&& newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS;
|
||||
&& newest_pts.saturating_sub(f.pts_ns) <= window_ns;
|
||||
if !keep {
|
||||
// Remember the abandoned index so a straggler shard is dropped (below, and in
|
||||
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
|
||||
@@ -831,6 +882,28 @@ impl ReassemblyWindow {
|
||||
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();
|
||||
// Partial delivery (chunk-aligned AUs only): the buffer is already exactly
|
||||
// what the consumer needs — received shards at their final offsets, zeros
|
||||
// where shards are missing (the codec's block walk skips zero windows).
|
||||
// Newest-wins if several age out in one prune. Still counted dropped below.
|
||||
if let Some(sink) = partial_sink.as_deref_mut() {
|
||||
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 {
|
||||
let mut buf = std::mem::take(&mut f.buf);
|
||||
buf.truncate(f.frame_bytes);
|
||||
let newer = sink
|
||||
.as_ref()
|
||||
.is_none_or(|p| idx.wrapping_sub(p.frame_index) <= u32::MAX / 2);
|
||||
if newer {
|
||||
*sink = Some(Frame {
|
||||
data: buf,
|
||||
frame_index: idx,
|
||||
pts_ns: f.pts_ns,
|
||||
flags: f.user_flags,
|
||||
complete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for block in f.blocks.values_mut() {
|
||||
for slot in block.recovery.iter_mut() {
|
||||
if let Some(rb) = slot.take() {
|
||||
|
||||
@@ -161,6 +161,12 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
|
||||
pub(super) const RICH_MOTION: u8 = 0x02;
|
||||
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
pub(super) const RICH_HID_REPORT: u8 = 0x04;
|
||||
|
||||
/// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
|
||||
/// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
|
||||
/// 46–54 bytes; feature and output reports are at most 64).
|
||||
pub const HID_REPORT_MAX: usize = 64;
|
||||
|
||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -206,6 +212,19 @@ pub enum RichInput {
|
||||
y: i16,
|
||||
pressure: u16,
|
||||
},
|
||||
/// One raw HID input report from a client-captured controller, forwarded verbatim for a
|
||||
/// host backend that mirrors the physical device as-is (the Steam Controller 2 / Triton
|
||||
/// passthrough — [`GamepadPref::SteamController2`](crate::config::GamepadPref)). `data[..len]`
|
||||
/// is exactly what the device produced on its interrupt endpoint / GATT notify, report-id
|
||||
/// byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …). Best-effort like the rest of
|
||||
/// the plane: state reports are idempotent snapshots at the device's own rate, so a lost
|
||||
/// datagram self-heals on the next one. Fixed-size body keeps the type `Copy` on a path that
|
||||
/// runs at the controller's report rate.
|
||||
HidReport {
|
||||
pad: u8,
|
||||
len: u8,
|
||||
data: [u8; HID_REPORT_MAX],
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
@@ -245,6 +264,11 @@ impl RichInput {
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
out.extend_from_slice(&pressure.to_le_bytes());
|
||||
}
|
||||
RichInput::HidReport { pad, len, ref data } => {
|
||||
let len = (len as usize).min(HID_REPORT_MAX);
|
||||
out.extend_from_slice(&[RICH_HID_REPORT, pad, len as u8]);
|
||||
out.extend_from_slice(&data[..len]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -279,6 +303,18 @@ impl RichInput {
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||
}),
|
||||
RICH_HID_REPORT if b.len() >= 4 => {
|
||||
// Every byte read below is bounded: `len` is clamped to the fixed body size AND
|
||||
// to what the buffer actually holds (a torn datagram truncates, never over-reads).
|
||||
let len = (b[3] as usize).min(HID_REPORT_MAX).min(b.len() - 4);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
data[..len].copy_from_slice(&b[4..4 + len]);
|
||||
Some(RichInput::HidReport {
|
||||
pad: b[2],
|
||||
len: len as u8,
|
||||
data,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -288,6 +324,16 @@ const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
/// it on the physical device's interrupt-OUT endpoint / GATT write.
|
||||
pub const HID_RAW_OUTPUT: u8 = 0;
|
||||
/// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
|
||||
/// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
|
||||
/// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
|
||||
pub const HID_RAW_FEATURE: u8 = 1;
|
||||
|
||||
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -311,6 +357,14 @@ pub enum HidOutput {
|
||||
period: u16,
|
||||
count: u16,
|
||||
},
|
||||
/// A raw report the host's hidraw consumer (Steam) wrote to an as-is passthrough pad
|
||||
/// ([`RichInput::HidReport`]'s reverse direction), for the client to replay verbatim on the
|
||||
/// physical device. `kind` is [`HID_RAW_OUTPUT`] or [`HID_RAW_FEATURE`]; `data` is the full
|
||||
/// report, id byte first, at most [`HID_REPORT_MAX`] bytes. Best-effort is sound here by the
|
||||
/// device protocol's own design: Triton rumble is re-sent every ~40 ms against a ~50 ms
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -339,6 +393,10 @@ impl HidOutput {
|
||||
out.extend_from_slice(&period.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -370,6 +428,12 @@ impl HidOutput {
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
count: u16::from_le_bytes([b[8], b[9]]),
|
||||
}),
|
||||
HIDOUT_HID_RAW if b.len() >= 5 => Some(HidOutput::HidRaw {
|
||||
pad: b[2],
|
||||
kind: b[3],
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ pub const QUIT_CLOSE_CODE: u32 = 0x51;
|
||||
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
||||
|
||||
// Typed rejection close codes + [`RejectReason`] live in `crate::reject` (ungated — the
|
||||
// error enum references them even in `quic`-less builds) and are re-exported here so the
|
||||
// wire vocabulary stays browsable next to QUIT/APP_EXITED.
|
||||
pub use crate::reject::*;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||
@@ -151,15 +156,28 @@ pub const CODEC_H264: u8 = 0x01;
|
||||
pub const CODEC_HEVC: u8 = 0x02;
|
||||
/// [`Hello::video_codecs`] bit: the client can decode AV1.
|
||||
pub const CODEC_AV1: u8 = 0x04;
|
||||
/// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN
|
||||
/// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR,
|
||||
/// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s
|
||||
/// precedence ladder**: it is selected only when the client also names it
|
||||
/// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec
|
||||
/// that needs a wired-LAN bitrate must never win a negotiation just because both ends support
|
||||
/// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin"
|
||||
/// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
|
||||
/// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
|
||||
/// version instead (plan §4.2).
|
||||
pub const CODEC_PYROWAVE: u8 = 0x08;
|
||||
|
||||
/// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`]
|
||||
/// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen
|
||||
/// encoder can produce (`host_capable`, also a bitfield). `preferred` is the client's soft preference
|
||||
/// ([`Hello::preferred_codec`], `0` = none): when it's in the shared set it wins; otherwise the tie is
|
||||
/// broken by **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the
|
||||
/// compatibility / software floor). Returns the single-bit codec value, or `None` when client and host
|
||||
/// share nothing — the caller then refuses the session with a clear error rather than emitting a
|
||||
/// stream the client can't decode.
|
||||
/// compatibility / software floor). [`CODEC_PYROWAVE`] is intentionally NOT in that ladder — it can
|
||||
/// only be returned via the `preferred` path (plan §3: opt-in, pinned, honest). Returns the
|
||||
/// single-bit codec value, or `None` when client and host share nothing the ladder may pick — the
|
||||
/// caller then refuses the session with a clear error rather than emitting a stream the client
|
||||
/// can't decode.
|
||||
pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Option<u8> {
|
||||
// An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent.
|
||||
let client = if client_codecs == 0 {
|
||||
@@ -942,6 +960,7 @@ impl Welcome {
|
||||
codec: match b.get(66).copied() {
|
||||
Some(CODEC_H264) => CODEC_H264,
|
||||
Some(CODEC_AV1) => CODEC_AV1,
|
||||
Some(CODEC_PYROWAVE) => CODEC_PYROWAVE,
|
||||
_ => CODEC_HEVC,
|
||||
},
|
||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||
|
||||
@@ -101,6 +101,69 @@ fn codec_negotiation_and_back_compat() {
|
||||
// A preference the host can't emit still can't rescue a no-shared-codec case.
|
||||
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
|
||||
|
||||
// PyroWave is opt-in ONLY (plan §3): mutual support NEVER auto-selects it — the ladder
|
||||
// ignores it entirely...
|
||||
assert_eq!(
|
||||
resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC | CODEC_PYROWAVE, 0),
|
||||
Some(CODEC_HEVC)
|
||||
);
|
||||
// ...even when it is the ONLY shared codec (an all-intra 200 Mbps stream must never be a
|
||||
// silent fallback)...
|
||||
assert_eq!(resolve_codec(CODEC_PYROWAVE, CODEC_PYROWAVE, 0), None);
|
||||
// ...it is reachable exclusively through the client's explicit preference.
|
||||
assert_eq!(
|
||||
resolve_codec(
|
||||
CODEC_HEVC | CODEC_PYROWAVE,
|
||||
CODEC_HEVC | CODEC_PYROWAVE,
|
||||
CODEC_PYROWAVE
|
||||
),
|
||||
Some(CODEC_PYROWAVE)
|
||||
);
|
||||
// A pyrowave preference against a host without the backend falls back to the ladder.
|
||||
assert_eq!(
|
||||
resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC, CODEC_PYROWAVE),
|
||||
Some(CODEC_HEVC)
|
||||
);
|
||||
// And the negotiated bit SURVIVES the Welcome wire roundtrip — the decode whitelist
|
||||
// once folded unknown codec bytes (incl. PyroWave) to HEVC, which sent wavelet AUs
|
||||
// into an FFmpeg HEVC decoder on the first on-glass run.
|
||||
let mut pw_w = Welcome::decode(
|
||||
&Welcome {
|
||||
abi_version: 2,
|
||||
udp_port: 1,
|
||||
mode: Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0,
|
||||
max_data_per_block: 1024,
|
||||
},
|
||||
shard_payload: 1024,
|
||||
encrypt: false,
|
||||
key: [0; 16],
|
||||
salt: [0; 4],
|
||||
frames: 0,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 0,
|
||||
bit_depth: 8,
|
||||
color: ColorInfo::SDR_BT709,
|
||||
chroma_format: CHROMA_IDC_420,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_PYROWAVE,
|
||||
host_caps: 0,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pw_w.codec, CODEC_PYROWAVE);
|
||||
// A genuinely unknown future bit still folds to the HEVC default.
|
||||
pw_w.codec = 0x40;
|
||||
assert_eq!(Welcome::decode(&pw_w.encode()).unwrap().codec, CODEC_HEVC);
|
||||
|
||||
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
|
||||
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
|
||||
let h = Hello {
|
||||
@@ -279,11 +342,13 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::SteamDeck,
|
||||
GamepadPref::DualSenseEdge,
|
||||
GamepadPref::SwitchPro,
|
||||
GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers
|
||||
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
|
||||
// that only know a prefix of the range).
|
||||
for (v, p) in [
|
||||
(0, GamepadPref::Auto),
|
||||
@@ -295,12 +360,14 @@ fn gamepad_pref_wire_and_names() {
|
||||
(6, GamepadPref::SteamDeck),
|
||||
(7, GamepadPref::DualSenseEdge),
|
||||
(8, GamepadPref::SwitchPro),
|
||||
(9, GamepadPref::SteamController2),
|
||||
(10, GamepadPref::SteamController2Puck),
|
||||
] {
|
||||
assert_eq!(p.to_u8(), v);
|
||||
assert_eq!(GamepadPref::from_u8(v), p);
|
||||
}
|
||||
// The next unassigned byte degrades to Auto today; assigning it later must update this.
|
||||
assert_eq!(GamepadPref::from_u8(9), GamepadPref::Auto);
|
||||
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
@@ -314,6 +381,18 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::from_name("Switch-Pro"),
|
||||
Some(GamepadPref::SwitchPro)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("ibex"),
|
||||
Some(GamepadPref::SteamController2)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("sc2"),
|
||||
Some(GamepadPref::SteamController2)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("sc2puck"),
|
||||
Some(GamepadPref::SteamController2Puck)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
@@ -1134,6 +1213,33 @@ fn rich_input_roundtrip() {
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
assert_eq!(RichInput::decode(&d), Some(ev));
|
||||
}
|
||||
// A raw Triton state report rides the plane verbatim (as-is SC2 passthrough).
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
data[0] = 0x42; // ID_TRITON_CONTROLLER_STATE
|
||||
for (i, b) in data.iter_mut().enumerate().take(46).skip(1) {
|
||||
*b = i as u8;
|
||||
}
|
||||
let raw = RichInput::HidReport {
|
||||
pad: 3,
|
||||
len: 46,
|
||||
data,
|
||||
};
|
||||
let d = raw.encode();
|
||||
assert_eq!(d.len(), 4 + 46); // tag + kind + pad + len + body — no fixed-array padding
|
||||
assert_eq!(RichInput::decode(&d), Some(raw));
|
||||
// A torn HidReport truncates to what arrived rather than over-reading (len clamps).
|
||||
assert_eq!(
|
||||
RichInput::decode(&d[..20]),
|
||||
Some(RichInput::HidReport {
|
||||
pad: 3,
|
||||
len: 16,
|
||||
data: {
|
||||
let mut t = [0u8; HID_REPORT_MAX];
|
||||
t[..16].copy_from_slice(&data[..16]);
|
||||
t
|
||||
},
|
||||
})
|
||||
);
|
||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
@@ -1167,6 +1273,23 @@ fn hid_output_roundtrip() {
|
||||
period: 0x5678,
|
||||
count: 9,
|
||||
},
|
||||
// A raw Triton rumble output report (as-is SC2 passthrough, host→client).
|
||||
HidOutput::HidRaw {
|
||||
pad: 1,
|
||||
kind: HID_RAW_OUTPUT,
|
||||
data: vec![0x80, 0, 0, 0, 0x34, 0x12, 0, 0x78, 0x56, 0],
|
||||
},
|
||||
// A raw 64-byte feature report (lizard-off / IMU-enable settings write).
|
||||
HidOutput::HidRaw {
|
||||
pad: 0,
|
||||
kind: HID_RAW_FEATURE,
|
||||
data: {
|
||||
let mut f = vec![0u8; HID_REPORT_MAX];
|
||||
f[0] = 1; // Triton feature reports ride report id 1
|
||||
f[1] = 0x87; // ID_SET_SETTINGS_VALUES
|
||||
f
|
||||
},
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Why a host turns a connection away: typed QUIC application close codes + the
|
||||
//! [`RejectReason`] vocabulary shared by host and every client. Lives OUTSIDE the `quic`
|
||||
//! feature gate because [`PunktfunkError::Rejected`](crate::error::PunktfunkError::Rejected)
|
||||
//! carries it in every build; `crate::quic` re-exports it.
|
||||
|
||||
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission
|
||||
/// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code
|
||||
/// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it.
|
||||
pub const REJECT_BUSY_CLOSE_CODE: u32 = 0x42;
|
||||
|
||||
/// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can
|
||||
/// tell the user WHY it was turned away instead of collapsing every close into a generic
|
||||
/// "not accepted" (the failure mode behind more than one support thread: a PIN attempt against a
|
||||
/// disarmed host, an operator denial, and a dead network path all looked identical). Grouped in
|
||||
/// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end
|
||||
/// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the
|
||||
/// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`].
|
||||
pub const PAIR_NOT_ARMED_CLOSE_CODE: u32 = 0x60;
|
||||
/// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not
|
||||
/// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract.
|
||||
pub const PAIR_BOUND_OTHER_CLOSE_CODE: u32 = 0x61;
|
||||
/// PIN attempt inside the host's global pairing cooldown — retry shortly.
|
||||
pub const PAIR_RATE_LIMITED_CLOSE_CODE: u32 = 0x62;
|
||||
/// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an
|
||||
/// identity to bind — the PIN flow with a client identity is the way in.
|
||||
pub const PAIR_NO_IDENTITY_CLOSE_CODE: u32 = 0x63;
|
||||
/// The operator explicitly denied this pairing request in the host console.
|
||||
pub const PAIR_DENIED_CLOSE_CODE: u32 = 0x64;
|
||||
/// Nobody decided on the parked pairing request before the host's approval wait elapsed.
|
||||
pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65;
|
||||
/// This parked knock was superseded by a newer connection from the same device — only the
|
||||
/// newest is admitted on approval.
|
||||
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
||||
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
||||
|
||||
/// Why a host turned a connection away, decoded from the QUIC application close code — the
|
||||
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
||||
/// [`REJECT_BUSY_CLOSE_CODE`]. Surfaces as
|
||||
/// [`PunktfunkError::Rejected`](crate::error::PunktfunkError::Rejected) so every client can show
|
||||
/// the real reason ("pairing not armed", "denied in the console") instead of a generic failure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RejectReason {
|
||||
/// No pairing window is armed on the host (arm it in the console).
|
||||
PairingNotArmed,
|
||||
/// The armed window is bound to a different device fingerprint.
|
||||
PairingBoundToOtherDevice,
|
||||
/// Inside the host's pairing cooldown — retry shortly.
|
||||
PairingRateLimited,
|
||||
/// The client presented no certificate identity to approve/bind.
|
||||
IdentityRequired,
|
||||
/// The operator denied the request in the console.
|
||||
Denied,
|
||||
/// The parked request expired with no operator decision.
|
||||
ApprovalTimeout,
|
||||
/// A newer knock from the same device replaced this one.
|
||||
Superseded,
|
||||
/// Client/host wire versions differ.
|
||||
WireVersionMismatch,
|
||||
/// The host refused admission because a conflicting session is live.
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl RejectReason {
|
||||
/// Decode a QUIC application close code into a reason; `None` for codes outside the
|
||||
/// shared vocabulary (a bare/legacy close stays a plain transport error).
|
||||
pub fn from_close_code(code: u32) -> Option<Self> {
|
||||
Some(match code {
|
||||
PAIR_NOT_ARMED_CLOSE_CODE => Self::PairingNotArmed,
|
||||
PAIR_BOUND_OTHER_CLOSE_CODE => Self::PairingBoundToOtherDevice,
|
||||
PAIR_RATE_LIMITED_CLOSE_CODE => Self::PairingRateLimited,
|
||||
PAIR_NO_IDENTITY_CLOSE_CODE => Self::IdentityRequired,
|
||||
PAIR_DENIED_CLOSE_CODE => Self::Denied,
|
||||
PAIR_APPROVAL_TIMEOUT_CLOSE_CODE => Self::ApprovalTimeout,
|
||||
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
||||
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
||||
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The close code this reason travels as (inverse of [`Self::from_close_code`]).
|
||||
pub fn close_code(self) -> u32 {
|
||||
match self {
|
||||
Self::PairingNotArmed => PAIR_NOT_ARMED_CLOSE_CODE,
|
||||
Self::PairingBoundToOtherDevice => PAIR_BOUND_OTHER_CLOSE_CODE,
|
||||
Self::PairingRateLimited => PAIR_RATE_LIMITED_CLOSE_CODE,
|
||||
Self::IdentityRequired => PAIR_NO_IDENTITY_CLOSE_CODE,
|
||||
Self::Denied => PAIR_DENIED_CLOSE_CODE,
|
||||
Self::ApprovalTimeout => PAIR_APPROVAL_TIMEOUT_CLOSE_CODE,
|
||||
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
||||
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
||||
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable machine token (kebab-case) for FFI layers that pass the reason as a string
|
||||
/// (e.g. the Android JNI bridge). Do not reword existing tokens — clients match on them.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PairingNotArmed => "not-armed",
|
||||
Self::PairingBoundToOtherDevice => "bound-other",
|
||||
Self::PairingRateLimited => "rate-limited",
|
||||
Self::IdentityRequired => "identity-required",
|
||||
Self::Denied => "denied",
|
||||
Self::ApprovalTimeout => "approval-timeout",
|
||||
Self::Superseded => "superseded",
|
||||
Self::WireVersionMismatch => "wire-version",
|
||||
Self::Busy => "busy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RejectReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::PairingNotArmed => "pairing is not armed on the host",
|
||||
Self::PairingBoundToOtherDevice => {
|
||||
"the host's pairing window is armed for a different device"
|
||||
}
|
||||
Self::PairingRateLimited => "pairing attempts are rate-limited — retry shortly",
|
||||
Self::IdentityRequired => "the host requires a client identity",
|
||||
Self::Denied => "the request was denied on the host",
|
||||
Self::ApprovalTimeout => "nobody approved the request on the host in time",
|
||||
Self::Superseded => "a newer request from this device replaced this one",
|
||||
Self::WireVersionMismatch => "client and host versions do not match",
|
||||
Self::Busy => "the host is busy with another session",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [RejectReason; 9] = [
|
||||
RejectReason::PairingNotArmed,
|
||||
RejectReason::PairingBoundToOtherDevice,
|
||||
RejectReason::PairingRateLimited,
|
||||
RejectReason::IdentityRequired,
|
||||
RejectReason::Denied,
|
||||
RejectReason::ApprovalTimeout,
|
||||
RejectReason::Superseded,
|
||||
RejectReason::WireVersionMismatch,
|
||||
RejectReason::Busy,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn close_codes_round_trip() {
|
||||
for r in ALL {
|
||||
assert_eq!(RejectReason::from_close_code(r.close_code()), Some(r));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_unique() {
|
||||
let mut codes: Vec<u32> = ALL.iter().map(|r| r.close_code()).collect();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), ALL.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_codes_stay_untyped() {
|
||||
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
|
||||
// never read as a host rejection.
|
||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x68, u32::MAX] {
|
||||
assert_eq!(RejectReason::from_close_code(code), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ pub struct Frame {
|
||||
pub frame_index: u32,
|
||||
pub pts_ns: u64,
|
||||
pub flags: u32,
|
||||
/// `false` = a partial delivery: the frame aged out of the loss window with shards
|
||||
/// missing, and the session opted into receiving it anyway
|
||||
/// ([`Session::set_deliver_partial_frames`]). Only chunk-aligned AUs
|
||||
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
|
||||
/// shard ranges are zero-filled at their exact offsets.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
|
||||
@@ -634,6 +640,20 @@ impl Session {
|
||||
|
||||
/// Client: drain the transport until a whole access unit is recovered, or no more
|
||||
/// packets are pending ([`PunktfunkError::NoFrame`]).
|
||||
/// Client opt-in: deliver aged-out incomplete chunk-aligned frames as
|
||||
/// [`Frame`]`{ complete: false }` instead of only dropping them (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4 — a lost datagram costs a few blocks of blur,
|
||||
/// not the frame). No effect on other codecs' AUs (they never carry the flag).
|
||||
pub fn set_deliver_partial_frames(&mut self, on: bool) {
|
||||
self.reassembler.set_deliver_partial(on);
|
||||
}
|
||||
|
||||
/// The session's negotiated wire shard payload size (bytes of AU per datagram) —
|
||||
/// the window size for chunk-aligned AUs (`USER_FLAG_CHUNK_ALIGNED`).
|
||||
pub fn shard_payload(&self) -> usize {
|
||||
self.config.shard_payload
|
||||
}
|
||||
|
||||
pub fn poll_frame(&mut self) -> Result<Frame> {
|
||||
if self.config.role != Role::Client {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
@@ -661,6 +681,11 @@ impl Session {
|
||||
}
|
||||
self.recv_idx = 0;
|
||||
if self.recv_count == 0 {
|
||||
// Nothing new on the wire — hand over an aged-out partial if one is
|
||||
// waiting (it can only get staler).
|
||||
if let Some(p) = self.reassembler.take_partial() {
|
||||
return Ok(p);
|
||||
}
|
||||
return Err(PunktfunkError::NoFrame);
|
||||
}
|
||||
}
|
||||
@@ -725,6 +750,11 @@ impl Session {
|
||||
StatsCounters::add(&self.stats.frames_completed, 1);
|
||||
return Ok(frame);
|
||||
}
|
||||
// A push that completed nothing may still have aged a partial out — deliver it
|
||||
// ahead of further draining (its successors are already arriving).
|
||||
if let Some(p) = self.reassembler.take_partial() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,6 +1010,101 @@ mod wire_equivalence_tests {
|
||||
fn pattern(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 31 + 7) as u8).collect()
|
||||
}
|
||||
|
||||
/// Partial delivery (plan §4.4): a chunk-aligned frame that loses shards past FEC's
|
||||
/// reach is DELIVERED once it ages out — `complete: false`, received shards at their
|
||||
/// exact offsets, missing ranges zero-filled — instead of silently dropping. Plain
|
||||
/// AUs (no flag) keep the drop behavior even with the opt-in enabled.
|
||||
#[test]
|
||||
fn partial_delivery_of_chunk_aligned_frames() {
|
||||
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
|
||||
let mk = |role| Config {
|
||||
role,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0, // no parity — any drop leaves a hole
|
||||
max_data_per_block: 64,
|
||||
},
|
||||
shard_payload: 1024,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
// Drop exactly one datagram (the 3rd) out of the first frame's shards.
|
||||
let (h, c) = crate::transport::loopback_pair(3, 1);
|
||||
let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap();
|
||||
let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap();
|
||||
client.set_deliver_partial_frames(true);
|
||||
|
||||
// 8 shards of chunk-aligned payload.
|
||||
let frame = pattern(8 * 1024);
|
||||
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
|
||||
.unwrap();
|
||||
// The incomplete frame ages out on the HARD index window — push enough newer
|
||||
// (complete) frames past it. Collect everything the client emits.
|
||||
let mut got_partial = None;
|
||||
let mut completes = 0;
|
||||
for i in 0..80u64 {
|
||||
let filler = pattern(1024);
|
||||
host.submit_frame(&filler, 2_000 + i, USER_FLAG_CHUNK_ALIGNED)
|
||||
.unwrap();
|
||||
loop {
|
||||
match client.poll_frame() {
|
||||
Ok(f) if !f.complete => got_partial = Some(f),
|
||||
Ok(_) => completes += 1,
|
||||
Err(PunktfunkError::NoFrame) => break,
|
||||
Err(e) => panic!("unexpected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = got_partial.expect("the lossy frame must be delivered partial");
|
||||
assert_eq!(p.pts_ns, 1_000);
|
||||
assert_eq!(p.data.len(), frame.len());
|
||||
assert!(p.flags & USER_FLAG_CHUNK_ALIGNED != 0);
|
||||
// Exactly one 1024-byte shard is zeroed; every other offset matches the original.
|
||||
let mut zero_windows = 0;
|
||||
for w in 0..8 {
|
||||
let win = &p.data[w * 1024..(w + 1) * 1024];
|
||||
if win.iter().all(|&b| b == 0) {
|
||||
zero_windows += 1;
|
||||
} else {
|
||||
assert_eq!(win, &frame[w * 1024..(w + 1) * 1024], "window {w} corrupt");
|
||||
}
|
||||
}
|
||||
// loopback_pair(3, _) drops every 3rd datagram, so several of the 8 shards are
|
||||
// gone — the exact count depends on phase; what matters is that SOME are zeroed
|
||||
// and every survivor is intact.
|
||||
assert!(
|
||||
(1..8).contains(&zero_windows),
|
||||
"dropped shards zero-filled (got {zero_windows})"
|
||||
);
|
||||
assert!(completes > 40, "surviving filler frames flow normally");
|
||||
|
||||
// Control: WITHOUT the flag the same loss is a plain drop, opt-in or not.
|
||||
let (h2, c2) = crate::transport::loopback_pair(3, 1);
|
||||
let mut host2 = Session::new(mk(Role::Host), Box::new(h2)).unwrap();
|
||||
let mut client2 = Session::new(mk(Role::Client), Box::new(c2)).unwrap();
|
||||
client2.set_deliver_partial_frames(true);
|
||||
host2.submit_frame(&pattern(8 * 1024), 1_000, 0).unwrap();
|
||||
let mut saw_partial = false;
|
||||
for i in 0..80u64 {
|
||||
host2.submit_frame(&pattern(1024), 2_000 + i, 0).unwrap();
|
||||
loop {
|
||||
match client2.poll_frame() {
|
||||
Ok(f) => saw_partial |= !f.complete,
|
||||
Err(PunktfunkError::NoFrame) => break,
|
||||
Err(e) => panic!("unexpected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!saw_partial,
|
||||
"unflagged AUs must never be delivered partial"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -26,6 +26,7 @@ mdns-sd = "0.20"
|
||||
mac_address = "1"
|
||||
if-addrs = "0.13"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
parking_lot = "0.12"
|
||||
rsa = "0.9"
|
||||
sha2 = { version = "0.10", features = ["oid"] }
|
||||
aes = "0.8"
|
||||
@@ -80,6 +81,9 @@ log = "0.4"
|
||||
# crate vendors libopus (cmake-built from source — no system lib, no vcpkg), so it builds on Windows
|
||||
# MSVC too (needs CMake + NASM, both on the box). Both platforms that have an audio-capture backend.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only compiled
|
||||
# under `--features pyrowave`. Stub-empty on other targets, so the cfg here is belt-and-braces.
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
opus = "0.3"
|
||||
# Software H.264 encoder — the GPU-less encode path on both Linux and Windows (and a fallback when no
|
||||
# hardware encoder is available). The default `source` feature statically compiles OpenH264 (BSD-2) —
|
||||
@@ -248,6 +252,9 @@ pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
# PyroWave ships in every default build (the codec stays strictly opt-in per session — a client
|
||||
# must explicitly prefer CODEC_PYROWAVE; nothing changes for normal HEVC/AV1 sessions).
|
||||
default = ["pyrowave"]
|
||||
# NVENC hardware encode (Windows). OFF by default (it pulls the NVENC SDK crate); nothing is
|
||||
# needed at link time — the entry points are resolved at RUNTIME from the driver's
|
||||
# nvEncodeAPI64.dll (encode/windows/nvenc.rs `load_api`), so the same binary starts fine on
|
||||
@@ -265,6 +272,11 @@ amf-qsv = ["dep:ffmpeg-next"]
|
||||
# bindings already carried for the dmabuf zero-copy bridge). Runtime-gated further by
|
||||
# PUNKTFUNK_VULKAN_ENCODE (opt-in for now). Build the AMD/Intel RFI host with `--features vulkan-encode`.
|
||||
vulkan-encode = []
|
||||
# PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
|
||||
# Builds the vendored codec from source (crates/pyrowave-sys, CMake + bindgen; Linux/Windows —
|
||||
# the encoder backend itself is Linux-only, the Windows host just carries the library). ON by
|
||||
# default (see `default` above); sessions reach it only through explicit client opt-in.
|
||||
pyrowave = ["dep:pyrowave-sys"]
|
||||
|
||||
# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging
|
||||
# builds of this crate never execute the winresource block).
|
||||
|
||||
@@ -114,6 +114,26 @@ pub(crate) fn gpu_encode() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
|
||||
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
|
||||
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
|
||||
/// devbuf / VA surface). The CPU de-pad path composites the cursor inline instead, so it leaves
|
||||
/// this `None`. `rgba` is `Arc` so attaching the (unchanged) bitmap to every frame is a refcount
|
||||
/// bump, not a copy; `serial` bumps only when the bitmap image changes, so the encoder re-uploads
|
||||
/// its small GPU texture on change and just moves a push-constant otherwise.
|
||||
#[derive(Clone)]
|
||||
pub struct CursorOverlay {
|
||||
/// Top-left in frame pixels where the bitmap is drawn (already = reported position − hotspot).
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
/// Straight-alpha RGBA pixels, `w*h*4` (bytes R,G,B,A).
|
||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||
/// Bumps whenever `rgba`/`w`/`h` change; stable across position-only moves.
|
||||
pub serial: u64,
|
||||
}
|
||||
|
||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||
@@ -124,6 +144,10 @@ pub struct CapturedFrame {
|
||||
/// Pixel layout of the payload.
|
||||
pub format: PixelFormat,
|
||||
pub payload: FramePayload,
|
||||
/// Cursor overlay to blend at encode time (GPU zero-copy payloads only); `None` when there's no
|
||||
/// visible cursor or the pixels were already composited on the CPU de-pad path. See
|
||||
/// [`CursorOverlay`].
|
||||
pub cursor: Option<CursorOverlay>,
|
||||
}
|
||||
|
||||
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||||
@@ -289,6 +313,7 @@ impl Capturer for SyntheticCapturer {
|
||||
pts_ns,
|
||||
format: PixelFormat::Bgrx,
|
||||
payload: FramePayload::Cpu(self.buf.clone()),
|
||||
cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -356,6 +381,7 @@ impl Capturer for FastSyntheticCapturer {
|
||||
pts_ns: 0,
|
||||
format: PixelFormat::Bgrx,
|
||||
payload: FramePayload::Cpu(self.buf.clone()),
|
||||
cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -405,7 +431,8 @@ pub fn capture_virtual_output(
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
"pf-vdisplay target not yet an active display path (activation failed — see the \
|
||||
virtual-display warnings above)"
|
||||
)
|
||||
})?;
|
||||
let pref = vout.preferred_mode;
|
||||
|
||||
@@ -374,10 +374,53 @@ impl Drop for PortalCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`),
|
||||
/// preferring **cursor-as-metadata**: the compositor keeps its cheap hardware cursor plane and
|
||||
/// ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + an occasional bitmap),
|
||||
/// which the consumer composites itself. That avoids forcing the producer to burn the cursor into
|
||||
/// every frame — the `Embedded` mode — which on gamescope would defeat its HW cursor plane. Falls
|
||||
/// back to `Embedded`, then `Hidden`, and (if the property query fails, e.g. an older portal)
|
||||
/// keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||
async fn choose_cursor_mode(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match proxy.available_cursor_modes().await {
|
||||
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||
);
|
||||
CursorMode::Embedded
|
||||
}
|
||||
Ok(avail) => {
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
|
||||
);
|
||||
CursorMode::Hidden
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
|
||||
);
|
||||
CursorMode::Embedded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive.
|
||||
fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -406,11 +449,12 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
let cursor_mode = choose_cursor_mode(&proxy).await;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// Only MONITOR is offered by the wlroots backend
|
||||
// (AvailableSourceTypes=1); requesting unsupported types
|
||||
// invalidates the session.
|
||||
@@ -466,7 +510,7 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
|
||||
/// identical.
|
||||
fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -509,11 +553,12 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
|
||||
.context("select_devices")?
|
||||
.response()
|
||||
.context("select_devices rejected")?;
|
||||
let cursor_mode = choose_cursor_mode(&screencast).await;
|
||||
screencast
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
.set_cursor_mode(cursor_mode)
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
@@ -586,6 +631,47 @@ mod pipewire {
|
||||
})
|
||||
}
|
||||
|
||||
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
|
||||
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
|
||||
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
|
||||
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
|
||||
#[derive(Default)]
|
||||
struct CursorState {
|
||||
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
|
||||
visible: bool,
|
||||
/// Top-left where the bitmap is drawn = reported position − hotspot.
|
||||
x: i32,
|
||||
y: i32,
|
||||
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
|
||||
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
|
||||
rgba: Arc<Vec<u8>>,
|
||||
bw: u32,
|
||||
bh: u32,
|
||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||
serial: u64,
|
||||
/// One-shot guard for the "cursor present but this frame is zero-copy" notice.
|
||||
warned_zerocopy: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// A shareable overlay for the GPU encode paths (blended at encode time), or `None` when
|
||||
/// there is nothing to draw. Cheap: clones an `Arc` + a few scalars.
|
||||
fn overlay(&self) -> Option<crate::capture::CursorOverlay> {
|
||||
if !self.visible || self.rgba.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::capture::CursorOverlay {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
w: self.bw,
|
||||
h: self.bh,
|
||||
rgba: self.rgba.clone(),
|
||||
serial: self.serial,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct UserData {
|
||||
info: VideoInfoRaw,
|
||||
/// Negotiated layout (`None` until param_changed, or if unsupported).
|
||||
@@ -624,6 +710,8 @@ mod pipewire {
|
||||
yuv444: bool,
|
||||
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
|
||||
dbg_log_n: u64,
|
||||
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
||||
cursor: CursorState,
|
||||
}
|
||||
|
||||
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
|
||||
@@ -876,6 +964,202 @@ mod pipewire {
|
||||
})
|
||||
}
|
||||
|
||||
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
|
||||
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
|
||||
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
|
||||
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
|
||||
fn build_cursor_meta_param() -> Result<Vec<u8>> {
|
||||
fn meta_size(w: u32, h: u32) -> i32 {
|
||||
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
|
||||
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
|
||||
+ (w as usize * h as usize * 4)) as i32
|
||||
}
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
|
||||
id: pw::spa::param::ParamType::Meta.as_raw(),
|
||||
properties: vec![
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_type,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
|
||||
},
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_size,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: meta_size(64, 64),
|
||||
min: meta_size(1, 1),
|
||||
max: meta_size(256, 256),
|
||||
},
|
||||
),
|
||||
)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
|
||||
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
|
||||
/// 4-byte formats are read as RGBA.
|
||||
fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
match vfmt {
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
|
||||
_ => (s[0], s[1], s[2], s[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||
// `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
|
||||
// `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
|
||||
// (or null), valid until requeue. The size argument matches the struct the result is cast to.
|
||||
let cur = unsafe {
|
||||
spa::sys::spa_buffer_find_meta_data(
|
||||
spa_buf,
|
||||
spa::sys::SPA_META_Cursor,
|
||||
std::mem::size_of::<spa::sys::spa_meta_cursor>(),
|
||||
) as *const spa::sys::spa_meta_cursor
|
||||
};
|
||||
if cur.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
|
||||
// inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
(*cur).position.x,
|
||||
(*cur).position.y,
|
||||
(*cur).hotspot.x,
|
||||
(*cur).hotspot.y,
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// Compositor reports no visible pointer (e.g. a game grabbed/hid it).
|
||||
cursor.visible = false;
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
if bmp_off == 0 {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
// SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
|
||||
// producer placed inside the same meta region it sized for this cursor (>= the size we
|
||||
// requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
|
||||
let bmp =
|
||||
unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
|
||||
// SAFETY: `bmp` is the in-bounds, aligned `spa_meta_bitmap` pointer computed just above; the
|
||||
// producer fully initialized this header, so reading its scalar fields is sound.
|
||||
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
||||
(
|
||||
(*bmp).format,
|
||||
(*bmp).size.width,
|
||||
(*bmp).size.height,
|
||||
(*bmp).stride.max(0) as usize,
|
||||
(*bmp).offset as usize,
|
||||
)
|
||||
};
|
||||
// Ignore empty or implausibly large bitmaps (we requested <= 256×256).
|
||||
if bw == 0 || bh == 0 || bw > 256 || bh > 256 {
|
||||
return;
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
let span = stride * (bh as usize - 1) + row;
|
||||
// SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
|
||||
// producer-sized meta region. `span` is the exact extent the strided copy below reads.
|
||||
let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
|
||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||
for y in 0..bh as usize {
|
||||
for x in 0..bw as usize {
|
||||
let so = y * stride + x * 4;
|
||||
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
|
||||
let d = (y * bw as usize + x) * 4;
|
||||
rgba[d] = r;
|
||||
rgba[d + 1] = g;
|
||||
rgba[d + 2] = b;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
}
|
||||
cursor.rgba = Arc::new(rgba);
|
||||
cursor.bw = bw;
|
||||
cursor.bh = bh;
|
||||
cursor.serial = cursor.serial.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
|
||||
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
|
||||
/// the CPU de-pad path anyway).
|
||||
fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
|
||||
Some(match fmt {
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
|
||||
PixelFormat::Rgb => (0, 1, 2, 3),
|
||||
PixelFormat::Bgr => (2, 1, 0, 3),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||
fn composite_cursor(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
fmt: PixelFormat,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
if !cursor.visible || cursor.rgba.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let (sr, sg, sb) = (
|
||||
cursor.rgba[s] as u32,
|
||||
cursor.rgba[s + 1] as u32,
|
||||
cursor.rgba[s + 2] as u32,
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * bpp;
|
||||
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
|
||||
tight[di + ri] = blend(tight[di + ri], sr);
|
||||
tight[di + gi] = blend(tight[di + gi], sg);
|
||||
tight[di + bi] = blend(tight[di + bi], sb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// De-pad / import a single PipeWire buffer and push it to the encoder. Called from the
|
||||
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
|
||||
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
|
||||
@@ -890,6 +1174,22 @@ mod pipewire {
|
||||
if ud.broken.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Cursor-as-metadata only reaches the frame on the CPU de-pad path below (a small
|
||||
// straight-alpha blit). The zero-copy paths hand a GPU-resident buffer straight to the
|
||||
// encoder, so the cached cursor can't be composited here — that needs a GPU blit in the
|
||||
// encoder (follow-up). Note it once, so a gamescope host (zero-copy by default) shows in
|
||||
// the logs that the metadata IS arriving even while the overlay isn't drawn yet.
|
||||
if ud.cursor.visible
|
||||
&& !ud.cursor.warned_zerocopy
|
||||
&& (ud.importer.is_some() || ud.vaapi_passthrough)
|
||||
{
|
||||
ud.cursor.warned_zerocopy = true;
|
||||
tracing::warn!(
|
||||
"cursor metadata received, but frames are delivered zero-copy (GPU-resident) — \
|
||||
the cursor overlay is composited only on the CPU capture path today; GPU-path \
|
||||
compositing (Vulkan/CUDA/VAAPI encode) is a follow-up"
|
||||
);
|
||||
}
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||
@@ -989,6 +1289,9 @@ mod pipewire {
|
||||
offset,
|
||||
stride,
|
||||
}),
|
||||
// Cursor-as-metadata: the encoder blends this into its owned VA
|
||||
// surface (raw dmabuf never touched).
|
||||
cursor: ud.cursor.overlay(),
|
||||
});
|
||||
static ONCE: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(true);
|
||||
@@ -998,7 +1301,7 @@ mod pipewire {
|
||||
h,
|
||||
modifier = ud.modifier,
|
||||
fourcc = format_args!("{:#010x}", fourcc),
|
||||
"zero-copy: handing dmabuf to VAAPI (GPU import + CSC)"
|
||||
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1077,6 +1380,9 @@ mod pipewire {
|
||||
fmt
|
||||
},
|
||||
payload: FramePayload::Cuda(devbuf),
|
||||
// Cursor-as-metadata: blended by the CUDA encoder into its owned
|
||||
// device surface. (RGB LINEAR-import case; YUV sessions blend planes.)
|
||||
cursor: ud.cursor.overlay(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1226,6 +1532,10 @@ mod pipewire {
|
||||
for y in 0..h {
|
||||
tight[y * row..y * row + row].copy_from_slice(®ion[y * stride..y * stride + row]);
|
||||
}
|
||||
// Cursor-as-metadata: blit the latched pointer into the frame (no-op when hidden or when
|
||||
// the layout isn't packed RGB). This is the CPU path's counterpart to the producer's
|
||||
// hardware cursor plane, which stays out of the captured buffer.
|
||||
composite_cursor(&mut tight, w, h, fmt, &ud.cursor);
|
||||
let pts_ns = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
@@ -1236,6 +1546,8 @@ mod pipewire {
|
||||
pts_ns,
|
||||
format: fmt,
|
||||
payload: FramePayload::Cpu(tight),
|
||||
// Already composited inline into `tight` above — nothing for the encoder to blend.
|
||||
cursor: None,
|
||||
};
|
||||
// Drop if the encoder is behind — never block the pipewire loop.
|
||||
let _ = ud.tx.try_send(frame);
|
||||
@@ -1328,6 +1640,23 @@ mod pipewire {
|
||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
}
|
||||
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
||||
// advertisement with every modifier its device samples from, so compositors that
|
||||
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
if vaapi_passthrough && crate::config::config().encoder_pref.as_str() == "pyrowave" {
|
||||
for m in crate::encode::pyrowave_capture_modifiers(
|
||||
crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap(),
|
||||
) {
|
||||
if !modifiers.contains(&m) {
|
||||
modifiers.push(m);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
|
||||
);
|
||||
}
|
||||
let want_dmabuf =
|
||||
(importer.is_some() || vaapi_passthrough) && !modifiers.is_empty() && !force_shm;
|
||||
if force_shm {
|
||||
@@ -1387,6 +1716,7 @@ mod pipewire {
|
||||
nv12: crate::zerocopy::nv12_enabled(),
|
||||
yuv444: want_444,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
};
|
||||
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
@@ -1498,6 +1828,11 @@ mod pipewire {
|
||||
// load through a valid pointer — no mutation or aliasing.
|
||||
let spa_buf = unsafe { (*newest).buffer };
|
||||
|
||||
// Refresh cursor-as-metadata BEFORE the stale-frame skip below: Mutter delivers
|
||||
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
|
||||
// frame, but their cursor meta is fresh and must still move our overlay.
|
||||
update_cursor_meta(&mut ud.cursor, spa_buf);
|
||||
|
||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
|
||||
@@ -1652,6 +1987,10 @@ mod pipewire {
|
||||
(None, Some(build_mappable_buffers()?))
|
||||
};
|
||||
|
||||
// Ask for cursor-as-metadata on every path (harmless if the producer can't supply it): the
|
||||
// pointer rides as SPA_META_Cursor rather than being burned into the frame, so the
|
||||
// compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`).
|
||||
let cursor_meta = build_cursor_meta_param()?;
|
||||
let mut byte_slices: Vec<&[u8]> = Vec::new();
|
||||
match &dmabuf_values {
|
||||
Some(d) => byte_slices.push(d),
|
||||
@@ -1660,6 +1999,7 @@ mod pipewire {
|
||||
if let Some(b) = &buffers_values {
|
||||
byte_slices.push(b);
|
||||
}
|
||||
byte_slices.push(&cursor_meta);
|
||||
let mut params: Vec<&Pod> = byte_slices
|
||||
.iter()
|
||||
.map(|&b| Pod::from_bytes(b).context("pod from bytes"))
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct WinCaptureTarget {
|
||||
pub adapter_luid: i64,
|
||||
/// The output's GDI device name, e.g. `\\.\DISPLAY3`. Can CHANGE across a secure-desktop switch.
|
||||
pub gdi_name: String,
|
||||
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
|
||||
/// Stable virtual-display (IddCx) target id — re-resolved to the current GDI name on every recovery.
|
||||
pub target_id: u32,
|
||||
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
|
||||
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
|
||||
|
||||
@@ -1676,6 +1676,7 @@ impl IddPushCapturer {
|
||||
texture: out,
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
cursor: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1703,6 +1704,7 @@ impl IddPushCapturer {
|
||||
texture: dst,
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
texture: self.default_tex.clone(),
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ pub struct HostConfig {
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
/// `PUNKTFUNK_GAMESCOPE_STEAM` — opt the bare headless gamescope spawn into its Steam
|
||||
/// integration mode (`--steam`). Managed gamescope-session-plus/SteamOS sessions own their
|
||||
/// own flags and do not consult this.
|
||||
pub gamescope_steam: bool,
|
||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
@@ -120,6 +124,12 @@ impl HostConfig {
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
gamescope_steam: val("PUNKTFUNK_GAMESCOPE_STEAM").is_some_and(|s| {
|
||||
matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ pub struct EncodedFrame {
|
||||
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
|
||||
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
|
||||
pub recovery_anchor: bool,
|
||||
/// The AU is shard-aligned self-delimiting chunks (see [`Encoder::set_wire_chunking`]);
|
||||
/// the session stamps [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`] so the client
|
||||
/// windows its parse and may opt into partial delivery. Only the PyroWave backend sets it.
|
||||
pub chunk_aligned: bool,
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
@@ -37,6 +41,10 @@ pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
Av1,
|
||||
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
|
||||
/// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence
|
||||
/// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe.
|
||||
PyroWave,
|
||||
}
|
||||
|
||||
/// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the
|
||||
@@ -73,6 +81,7 @@ impl Codec {
|
||||
match bit {
|
||||
punktfunk_core::quic::CODEC_H264 => Codec::H264,
|
||||
punktfunk_core::quic::CODEC_AV1 => Codec::Av1,
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave,
|
||||
_ => Codec::H265,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +92,7 @@ impl Codec {
|
||||
Codec::H264 => punktfunk_core::quic::CODEC_H264,
|
||||
Codec::H265 => punktfunk_core::quic::CODEC_HEVC,
|
||||
Codec::Av1 => punktfunk_core::quic::CODEC_AV1,
|
||||
Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,49 +107,71 @@ impl Codec {
|
||||
/// still lands on HEVC for an auto client, exactly the pre-probe behaviour. Fed to
|
||||
/// [`punktfunk_core::quic::resolve_codec`] against the client's advertised codecs.
|
||||
pub fn host_wire_caps() -> u8 {
|
||||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||||
/// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends.
|
||||
const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264
|
||||
| punktfunk_core::quic::CODEC_HEVC
|
||||
| punktfunk_core::quic::CODEC_AV1;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if matches!(
|
||||
crate::config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
return punktfunk_core::quic::CODEC_H264;
|
||||
// PyroWave rides ON TOP of whatever H.26x set resolves below: feature-gated, Linux-only
|
||||
// for now (the Windows host encoder is future work), and inert in negotiation unless the
|
||||
// client explicitly prefers it (resolve_codec ignores the bit in its ladder). Advertised
|
||||
// whenever the backend could open: AMD/Intel capture hands raw dmabufs it imports
|
||||
// directly, and an NVIDIA-auto host's PyroWave sessions flip capture to CPU RGB
|
||||
// per-session instead ([`crate::session_plan::SessionPlan::output_format`]) — the EGL→CUDA
|
||||
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
||||
// keeps the bit off (no Vulkan device to open).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let pyro = if !matches!(
|
||||
crate::config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
punktfunk_core::quic::CODEC_PYROWAVE
|
||||
} else {
|
||||
0u8
|
||||
};
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
let pyro = 0u8;
|
||||
let base = (|| {
|
||||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||||
/// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends.
|
||||
const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264
|
||||
| punktfunk_core::quic::CODEC_HEVC
|
||||
| punktfunk_core::quic::CODEC_AV1;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if matches!(
|
||||
crate::config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
return punktfunk_core::quic::CODEC_H264;
|
||||
}
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = vaapi_codec_support().wire_mask() {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||||
GPU_SUPERSET
|
||||
}
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = vaapi_codec_support().wire_mask() {
|
||||
return m;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if windows_resolved_backend() == WindowsBackend::Software {
|
||||
return punktfunk_core::quic::CODEC_H264;
|
||||
}
|
||||
if windows_backend_is_probed() {
|
||||
if let Some(m) = windows_codec_support().wire_mask() {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
|
||||
GPU_SUPERSET
|
||||
}
|
||||
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = GPU_SUPERSET;
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
|
||||
_ => punktfunk_core::quic::CODEC_HEVC,
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||||
GPU_SUPERSET
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if windows_resolved_backend() == WindowsBackend::Software {
|
||||
return punktfunk_core::quic::CODEC_H264;
|
||||
}
|
||||
if windows_backend_is_probed() {
|
||||
if let Some(m) = windows_codec_support().wire_mask() {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
|
||||
GPU_SUPERSET
|
||||
}
|
||||
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = GPU_SUPERSET;
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
|
||||
_ => punktfunk_core::quic::CODEC_HEVC,
|
||||
}
|
||||
}
|
||||
})();
|
||||
base | pyro
|
||||
}
|
||||
|
||||
/// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into
|
||||
@@ -149,6 +181,7 @@ impl Codec {
|
||||
Codec::H264 => "h264",
|
||||
Codec::H265 => "hevc",
|
||||
Codec::Av1 => "av1",
|
||||
Codec::PyroWave => "pyrowave",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +192,9 @@ impl Codec {
|
||||
Codec::H264 => "h264_nvenc",
|
||||
Codec::H265 => "hevc_nvenc",
|
||||
Codec::Av1 => "av1_nvenc",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +208,9 @@ impl Codec {
|
||||
Codec::H264 => "h264_vaapi",
|
||||
Codec::H265 => "hevc_vaapi",
|
||||
Codec::Av1 => "av1_vaapi",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +221,9 @@ impl Codec {
|
||||
Codec::H264 => "h264_amf",
|
||||
Codec::H265 => "hevc_amf",
|
||||
Codec::Av1 => "av1_amf",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +234,9 @@ impl Codec {
|
||||
Codec::H264 => "h264_qsv",
|
||||
Codec::H265 => "hevc_qsv",
|
||||
Codec::Av1 => "av1_qsv",
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches a
|
||||
// libavcodec backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +357,13 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
/// datagram costs a few coefficient blocks, not the frame. AUs produced this way are
|
||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
@@ -324,7 +376,9 @@ impl Codec {
|
||||
pub fn max_dimension(self) -> u32 {
|
||||
match self {
|
||||
Codec::H264 => 4096,
|
||||
Codec::H265 | Codec::Av1 => 8192,
|
||||
// PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the
|
||||
// buffer-math guard the other codecs get.
|
||||
Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +393,9 @@ impl Codec {
|
||||
Codec::H264 => 480_000_000,
|
||||
Codec::H265 => 800_000_000,
|
||||
Codec::Av1 => 1_200_000_000,
|
||||
// No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's
|
||||
// own bitrate clamp so the step-down probe logic never binds below it.
|
||||
Codec::PyroWave => 8_000_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +425,15 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
|
||||
}
|
||||
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
|
||||
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
|
||||
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
|
||||
// rebuild after the switch was acked.
|
||||
if codec == Codec::PyroWave && (width < 128 || height < 128) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
|
||||
);
|
||||
}
|
||||
let max = codec.max_dimension();
|
||||
if width > max || height > max {
|
||||
anyhow::bail!(
|
||||
@@ -470,6 +536,12 @@ impl Encoder for TrackedEncoder {
|
||||
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
|
||||
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
||||
}
|
||||
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
|
||||
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
|
||||
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
|
||||
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
||||
self.inner.set_wire_chunking(shard_payload)
|
||||
}
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
self.inner.poll()
|
||||
}
|
||||
@@ -523,6 +595,20 @@ fn open_video_backend(
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
"session negotiated PyroWave but this host was built without --features \
|
||||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||||
);
|
||||
}
|
||||
// Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged);
|
||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
@@ -601,6 +687,31 @@ fn open_video_backend(
|
||||
)
|
||||
}
|
||||
}
|
||||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec. Explicit-only, and
|
||||
// EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2): no shipping
|
||||
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
||||
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
||||
// codec — every AU is an independently-decodable wavelet frame.
|
||||
"pyrowave" => {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"PUNKTFUNK_ENCODER=pyrowave forces the all-intra wavelet stream \
|
||||
regardless of the negotiated codec — only a pyrowave-feature client \
|
||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||
normal sessions negotiate it instead)"
|
||||
);
|
||||
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=pyrowave requires a build with --features punktfunk-host/pyrowave"
|
||||
)
|
||||
}
|
||||
}
|
||||
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
|
||||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||||
@@ -627,12 +738,17 @@ fn open_video_backend(
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, or software"
|
||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
||||
),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
|
||||
// host_wire_caps never advertises the bit here, so this only guards a forged preference.
|
||||
if codec == Codec::PyroWave {
|
||||
anyhow::bail!("PyroWave host encode is not available on Windows yet");
|
||||
}
|
||||
let _ = cuda; // always false on Windows (no Cuda payload)
|
||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||
// H.264. `auto` (the default) resolves from the selected render adapter's vendor.
|
||||
@@ -930,6 +1046,14 @@ fn linux_auto_is_vaapi() -> bool {
|
||||
!nvidia_present()
|
||||
}
|
||||
|
||||
/// The dmabuf modifiers the PyroWave encoder's Vulkan device imports for the capture's
|
||||
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
||||
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
pub(crate) fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
||||
pyrowave::capture_modifiers(fourcc)
|
||||
}
|
||||
|
||||
/// True if the Linux GPU encode backend resolves to VAAPI (AMD/Intel) rather than NVENC — mirrors
|
||||
/// [`open_video`]'s dispatch so the capturer can choose the matching zero-copy path (raw dmabuf
|
||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||
@@ -938,6 +1062,9 @@ pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => false,
|
||||
"vaapi" | "amd" | "intel" => true,
|
||||
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
||||
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
|
||||
"pyrowave" => true,
|
||||
_ => linux_auto_is_vaapi(),
|
||||
}
|
||||
}
|
||||
@@ -1274,6 +1401,20 @@ mod vulkan_video;
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[path = "encode/linux/vk_av1_encode.rs"]
|
||||
mod vk_av1_encode;
|
||||
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
||||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||||
#[cfg(all(
|
||||
target_os = "linux",
|
||||
any(feature = "vulkan-encode", feature = "pyrowave")
|
||||
))]
|
||||
#[path = "encode/linux/vk_util.rs"]
|
||||
mod vk_util;
|
||||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md §4.3):
|
||||
// pure Vulkan compute via the vendored `pyrowave-sys`, sub-ms encode, every frame a keyframe.
|
||||
// Explicit-only behind PUNKTFUNK_ENCODER=pyrowave; EXPERIMENTAL until CODEC_PYROWAVE lands.
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[path = "encode/linux/pyrowave.rs"]
|
||||
mod pyrowave;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Cursor-overlay blend kernels for the CUDA/NVENC path (cursor-as-metadata). The cursor bitmap is
|
||||
// straight-alpha RGBA, row-packed (stride = curW*4). Blended into the encoder-OWNED NVENC input
|
||||
// surface — never the compositor's dmabuf. One thread per cursor pixel (ARGB / YUV444) or per 2x2
|
||||
// chroma block (NV12). Coefficients are BT.709 limited, matching rgb2yuv.comp so the cursor colour
|
||||
// matches the rest of the frame regardless of which zero-copy backend encodes it.
|
||||
//
|
||||
// Build (regenerate cursor_blend.ptx after editing):
|
||||
// nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx
|
||||
// PTX is JIT'd by the driver forward to the actual GPU, so a compute_75 (Turing) baseline runs on
|
||||
// every Turing-or-newer NVENC GPU. (CUDA 13's nvcc no longer targets pre-Turing archs.)
|
||||
|
||||
typedef unsigned char u8;
|
||||
|
||||
__device__ __forceinline__ u8 blend8(int dst, int src, int a) {
|
||||
return (u8)((src * a + dst * (255 - a)) / 255);
|
||||
}
|
||||
|
||||
// Packed 4-byte surface. NVENC's ARGB format stores bytes B,G,R,A in memory; the cursor is R,G,B,A.
|
||||
extern "C" __global__ void blend_argb(
|
||||
u8* surf, int pitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
if (cx >= curW || cy >= curH) return;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) return;
|
||||
u8* d = surf + (size_t)py * pitch + (size_t)px * 4;
|
||||
d[0] = blend8(d[0], s[2], a); // B <- cursor B
|
||||
d[1] = blend8(d[1], s[1], a); // G <- cursor G
|
||||
d[2] = blend8(d[2], s[0], a); // R <- cursor R
|
||||
}
|
||||
|
||||
// Planar YUV444: three full-res planes stacked at base, base+plane, base+2*plane (plane=pitch*surfH).
|
||||
extern "C" __global__ void blend_yuv444(
|
||||
u8* base, int pitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
if (cx >= curW || cy >= curH) return;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) return;
|
||||
float R = s[0], G = s[1], B = s[2];
|
||||
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||
int U = (int)(128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B + 0.5f);
|
||||
int V = (int)(128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B + 0.5f);
|
||||
size_t plane = (size_t)pitch * surfH;
|
||||
u8* yp = base + (size_t)py * pitch + px;
|
||||
u8* up = base + plane + (size_t)py * pitch + px;
|
||||
u8* vp = base + 2 * plane + (size_t)py * pitch + px;
|
||||
*yp = blend8(*yp, Y, a);
|
||||
*up = blend8(*up, U, a);
|
||||
*vp = blend8(*vp, V, a);
|
||||
}
|
||||
|
||||
// NV12: full-res Y plane + interleaved half-res UV plane. One thread per 2x2 luma block; each blends
|
||||
// up to four Y samples and one (alpha-weighted) UV sample.
|
||||
extern "C" __global__ void blend_nv12(
|
||||
u8* yb, int yPitch, u8* uvb, int uvPitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int bx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int by = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
int base_cx = bx * 2, base_cy = by * 2;
|
||||
if (base_cx >= curW || base_cy >= curH) return;
|
||||
float ua = 0.0f, va = 0.0f, wa = 0.0f;
|
||||
int cnt = 0;
|
||||
for (int j = 0; j < 2; j++) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
int cx = base_cx + i, cy = base_cy + j;
|
||||
if (cx >= curW || cy >= curH) continue;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) continue;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) continue;
|
||||
float R = s[0], G = s[1], B = s[2];
|
||||
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||
u8* yp = yb + (size_t)py * yPitch + px;
|
||||
*yp = blend8(*yp, Y, a);
|
||||
ua += (128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B) * a;
|
||||
va += (128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B) * a;
|
||||
wa += a;
|
||||
cnt++;
|
||||
}
|
||||
}
|
||||
if (wa <= 0.0f || cnt == 0) return;
|
||||
// The chroma sample covering this block's top-left surface pixel.
|
||||
int uvx = (ox + base_cx) / 2;
|
||||
int uvy = (oy + base_cy) / 2;
|
||||
if (uvx < 0 || uvy < 0 || uvx * 2 >= surfW || uvy * 2 >= surfH) return;
|
||||
int U = (int)(ua / wa + 0.5f);
|
||||
int V = (int)(va / wa + 0.5f);
|
||||
int amean = (int)(wa / cnt + 0.5f);
|
||||
u8* uv = uvb + (size_t)uvy * uvPitch + (size_t)uvx * 2;
|
||||
uv[0] = blend8(uv[0], U, amean);
|
||||
uv[1] = blend8(uv[1], V, amean);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//
|
||||
// Generated by NVIDIA NVVM Compiler
|
||||
//
|
||||
// Compiler Build ID: CL-38244171
|
||||
// Cuda compilation tools, release 13.3, V13.3.73
|
||||
// Based on NVVM 7.0.1
|
||||
//
|
||||
|
||||
.version 9.3
|
||||
.target sm_75
|
||||
.address_size 64
|
||||
|
||||
// .globl blend_argb
|
||||
|
||||
.visible .entry blend_argb(
|
||||
.param .u64 blend_argb_param_0,
|
||||
.param .u32 blend_argb_param_1,
|
||||
.param .u32 blend_argb_param_2,
|
||||
.param .u32 blend_argb_param_3,
|
||||
.param .u64 blend_argb_param_4,
|
||||
.param .u32 blend_argb_param_5,
|
||||
.param .u32 blend_argb_param_6,
|
||||
.param .u32 blend_argb_param_7,
|
||||
.param .u32 blend_argb_param_8
|
||||
)
|
||||
{
|
||||
.reg .pred %p<10>;
|
||||
.reg .b16 %rs<2>;
|
||||
.reg .b32 %r<34>;
|
||||
.reg .b64 %rd<17>;
|
||||
|
||||
|
||||
ld.param.u64 %rd2, [blend_argb_param_0];
|
||||
ld.param.u32 %r5, [blend_argb_param_1];
|
||||
ld.param.u32 %r6, [blend_argb_param_2];
|
||||
ld.param.u32 %r7, [blend_argb_param_3];
|
||||
ld.param.u64 %rd3, [blend_argb_param_4];
|
||||
ld.param.u32 %r8, [blend_argb_param_5];
|
||||
ld.param.u32 %r11, [blend_argb_param_6];
|
||||
ld.param.u32 %r9, [blend_argb_param_7];
|
||||
ld.param.u32 %r10, [blend_argb_param_8];
|
||||
mov.u32 %r12, %ntid.x;
|
||||
mov.u32 %r13, %ctaid.x;
|
||||
mov.u32 %r14, %tid.x;
|
||||
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||
mov.u32 %r15, %ntid.y;
|
||||
mov.u32 %r16, %ctaid.y;
|
||||
mov.u32 %r17, %tid.y;
|
||||
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||
setp.ge.s32 %p1, %r1, %r8;
|
||||
setp.ge.s32 %p2, %r2, %r11;
|
||||
or.pred %p3, %p1, %p2;
|
||||
@%p3 bra $L__BB0_4;
|
||||
|
||||
add.s32 %r3, %r1, %r9;
|
||||
add.s32 %r4, %r2, %r10;
|
||||
or.b32 %r18, %r4, %r3;
|
||||
setp.lt.s32 %p4, %r18, 0;
|
||||
setp.ge.s32 %p5, %r3, %r6;
|
||||
or.pred %p6, %p5, %p4;
|
||||
setp.ge.s32 %p7, %r4, %r7;
|
||||
or.pred %p8, %p7, %p6;
|
||||
@%p8 bra $L__BB0_4;
|
||||
|
||||
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||
mul.wide.s32 %rd4, %r19, 4;
|
||||
cvta.to.global.u64 %rd5, %rd3;
|
||||
add.s64 %rd1, %rd5, %rd4;
|
||||
ld.global.u8 %rs1, [%rd1+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB0_4;
|
||||
|
||||
cvt.u32.u16 %r20, %rs1;
|
||||
mul.wide.s32 %rd6, %r4, %r5;
|
||||
mul.wide.s32 %rd7, %r3, 4;
|
||||
add.s64 %rd8, %rd6, %rd7;
|
||||
cvta.to.global.u64 %rd9, %rd2;
|
||||
add.s64 %rd10, %rd9, %rd8;
|
||||
ld.global.u8 %r21, [%rd10];
|
||||
ld.global.u8 %r22, [%rd1+2];
|
||||
xor.b32 %r23, %r20, 255;
|
||||
mul.lo.s32 %r24, %r23, %r21;
|
||||
mad.lo.s32 %r25, %r22, %r20, %r24;
|
||||
mul.wide.u32 %rd11, %r25, -2139062143;
|
||||
shr.u64 %rd12, %rd11, 39;
|
||||
st.global.u8 [%rd10], %rd12;
|
||||
ld.global.u8 %r26, [%rd10+1];
|
||||
ld.global.u8 %r27, [%rd1+1];
|
||||
mul.lo.s32 %r28, %r23, %r26;
|
||||
mad.lo.s32 %r29, %r27, %r20, %r28;
|
||||
mul.wide.u32 %rd13, %r29, -2139062143;
|
||||
shr.u64 %rd14, %rd13, 39;
|
||||
st.global.u8 [%rd10+1], %rd14;
|
||||
ld.global.u8 %r30, [%rd10+2];
|
||||
ld.global.u8 %r31, [%rd1];
|
||||
mul.lo.s32 %r32, %r23, %r30;
|
||||
mad.lo.s32 %r33, %r31, %r20, %r32;
|
||||
mul.wide.u32 %rd15, %r33, -2139062143;
|
||||
shr.u64 %rd16, %rd15, 39;
|
||||
st.global.u8 [%rd10+2], %rd16;
|
||||
|
||||
$L__BB0_4:
|
||||
ret;
|
||||
|
||||
}
|
||||
// .globl blend_yuv444
|
||||
.visible .entry blend_yuv444(
|
||||
.param .u64 blend_yuv444_param_0,
|
||||
.param .u32 blend_yuv444_param_1,
|
||||
.param .u32 blend_yuv444_param_2,
|
||||
.param .u32 blend_yuv444_param_3,
|
||||
.param .u64 blend_yuv444_param_4,
|
||||
.param .u32 blend_yuv444_param_5,
|
||||
.param .u32 blend_yuv444_param_6,
|
||||
.param .u32 blend_yuv444_param_7,
|
||||
.param .u32 blend_yuv444_param_8
|
||||
)
|
||||
{
|
||||
.reg .pred %p<10>;
|
||||
.reg .b16 %rs<5>;
|
||||
.reg .f32 %f<16>;
|
||||
.reg .b32 %r<49>;
|
||||
.reg .b64 %rd<14>;
|
||||
|
||||
|
||||
ld.param.u64 %rd2, [blend_yuv444_param_0];
|
||||
ld.param.u32 %r5, [blend_yuv444_param_1];
|
||||
ld.param.u32 %r6, [blend_yuv444_param_2];
|
||||
ld.param.u32 %r7, [blend_yuv444_param_3];
|
||||
ld.param.u64 %rd3, [blend_yuv444_param_4];
|
||||
ld.param.u32 %r8, [blend_yuv444_param_5];
|
||||
ld.param.u32 %r11, [blend_yuv444_param_6];
|
||||
ld.param.u32 %r9, [blend_yuv444_param_7];
|
||||
ld.param.u32 %r10, [blend_yuv444_param_8];
|
||||
mov.u32 %r12, %ntid.x;
|
||||
mov.u32 %r13, %ctaid.x;
|
||||
mov.u32 %r14, %tid.x;
|
||||
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||
mov.u32 %r15, %ntid.y;
|
||||
mov.u32 %r16, %ctaid.y;
|
||||
mov.u32 %r17, %tid.y;
|
||||
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||
setp.ge.s32 %p1, %r1, %r8;
|
||||
setp.ge.s32 %p2, %r2, %r11;
|
||||
or.pred %p3, %p1, %p2;
|
||||
@%p3 bra $L__BB1_4;
|
||||
|
||||
add.s32 %r3, %r1, %r9;
|
||||
add.s32 %r4, %r2, %r10;
|
||||
or.b32 %r18, %r4, %r3;
|
||||
setp.lt.s32 %p4, %r18, 0;
|
||||
setp.ge.s32 %p5, %r3, %r6;
|
||||
or.pred %p6, %p5, %p4;
|
||||
setp.ge.s32 %p7, %r4, %r7;
|
||||
or.pred %p8, %p7, %p6;
|
||||
@%p8 bra $L__BB1_4;
|
||||
|
||||
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||
mul.wide.s32 %rd4, %r19, 4;
|
||||
cvta.to.global.u64 %rd5, %rd3;
|
||||
add.s64 %rd1, %rd5, %rd4;
|
||||
ld.global.u8 %rs1, [%rd1+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB1_4;
|
||||
|
||||
cvt.u32.u16 %r20, %rs1;
|
||||
ld.global.u8 %rs2, [%rd1];
|
||||
cvt.rn.f32.u16 %f1, %rs2;
|
||||
ld.global.u8 %rs3, [%rd1+1];
|
||||
cvt.rn.f32.u16 %f2, %rs3;
|
||||
ld.global.u8 %rs4, [%rd1+2];
|
||||
cvt.rn.f32.u16 %f3, %rs4;
|
||||
fma.rn.f32 %f4, %f1, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f5, %f2, 0f3F1D3C36, %f4;
|
||||
fma.rn.f32 %f6, %f3, 0f3D7DF3B6, %f5;
|
||||
add.f32 %f7, %f6, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r21, %f7;
|
||||
fma.rn.f32 %f8, %f1, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f9, %f2, 0fBEAD5CFB, %f8;
|
||||
fma.rn.f32 %f10, %f3, 0f3EE0DED3, %f9;
|
||||
add.f32 %f11, %f10, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r22, %f11;
|
||||
fma.rn.f32 %f12, %f1, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f13, %f2, 0fBECC3C9F, %f12;
|
||||
fma.rn.f32 %f14, %f3, 0fBD25119D, %f13;
|
||||
add.f32 %f15, %f14, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r23, %f15;
|
||||
mul.wide.s32 %rd6, %r4, %r5;
|
||||
cvt.s64.s32 %rd7, %r3;
|
||||
add.s64 %rd8, %rd6, %rd7;
|
||||
cvta.to.global.u64 %rd9, %rd2;
|
||||
add.s64 %rd10, %rd9, %rd8;
|
||||
ld.global.u8 %r24, [%rd10];
|
||||
mul.lo.s32 %r25, %r21, %r20;
|
||||
xor.b32 %r26, %r20, 255;
|
||||
mad.lo.s32 %r27, %r26, %r24, %r25;
|
||||
mul.hi.s32 %r28, %r27, -2139062143;
|
||||
add.s32 %r29, %r28, %r27;
|
||||
shr.u32 %r30, %r29, 31;
|
||||
shr.u32 %r31, %r29, 7;
|
||||
add.s32 %r32, %r31, %r30;
|
||||
st.global.u8 [%rd10], %r32;
|
||||
mul.wide.s32 %rd11, %r7, %r5;
|
||||
add.s64 %rd12, %rd10, %rd11;
|
||||
ld.global.u8 %r33, [%rd12];
|
||||
mul.lo.s32 %r34, %r22, %r20;
|
||||
mad.lo.s32 %r35, %r26, %r33, %r34;
|
||||
mul.hi.s32 %r36, %r35, -2139062143;
|
||||
add.s32 %r37, %r36, %r35;
|
||||
shr.u32 %r38, %r37, 31;
|
||||
shr.u32 %r39, %r37, 7;
|
||||
add.s32 %r40, %r39, %r38;
|
||||
st.global.u8 [%rd12], %r40;
|
||||
add.s64 %rd13, %rd12, %rd11;
|
||||
ld.global.u8 %r41, [%rd13];
|
||||
mul.lo.s32 %r42, %r23, %r20;
|
||||
mad.lo.s32 %r43, %r26, %r41, %r42;
|
||||
mul.hi.s32 %r44, %r43, -2139062143;
|
||||
add.s32 %r45, %r44, %r43;
|
||||
shr.u32 %r46, %r45, 31;
|
||||
shr.u32 %r47, %r45, 7;
|
||||
add.s32 %r48, %r47, %r46;
|
||||
st.global.u8 [%rd13], %r48;
|
||||
|
||||
$L__BB1_4:
|
||||
ret;
|
||||
|
||||
}
|
||||
// .globl blend_nv12
|
||||
.visible .entry blend_nv12(
|
||||
.param .u64 blend_nv12_param_0,
|
||||
.param .u32 blend_nv12_param_1,
|
||||
.param .u64 blend_nv12_param_2,
|
||||
.param .u32 blend_nv12_param_3,
|
||||
.param .u32 blend_nv12_param_4,
|
||||
.param .u32 blend_nv12_param_5,
|
||||
.param .u64 blend_nv12_param_6,
|
||||
.param .u32 blend_nv12_param_7,
|
||||
.param .u32 blend_nv12_param_8,
|
||||
.param .u32 blend_nv12_param_9,
|
||||
.param .u32 blend_nv12_param_10
|
||||
)
|
||||
{
|
||||
.reg .pred %p<43>;
|
||||
.reg .b16 %rs<17>;
|
||||
.reg .f32 %f<108>;
|
||||
.reg .b32 %r<123>;
|
||||
.reg .b64 %rd<35>;
|
||||
|
||||
|
||||
ld.param.u64 %rd11, [blend_nv12_param_0];
|
||||
ld.param.u32 %r21, [blend_nv12_param_1];
|
||||
ld.param.u64 %rd10, [blend_nv12_param_2];
|
||||
ld.param.u32 %r22, [blend_nv12_param_3];
|
||||
ld.param.u32 %r23, [blend_nv12_param_4];
|
||||
ld.param.u32 %r24, [blend_nv12_param_5];
|
||||
ld.param.u64 %rd12, [blend_nv12_param_6];
|
||||
ld.param.u32 %r25, [blend_nv12_param_7];
|
||||
ld.param.u32 %r26, [blend_nv12_param_8];
|
||||
ld.param.u32 %r27, [blend_nv12_param_9];
|
||||
ld.param.u32 %r28, [blend_nv12_param_10];
|
||||
cvta.to.global.u64 %rd1, %rd11;
|
||||
cvta.to.global.u64 %rd2, %rd12;
|
||||
mov.u32 %r29, %ntid.x;
|
||||
mov.u32 %r30, %ctaid.x;
|
||||
mov.u32 %r31, %tid.x;
|
||||
mad.lo.s32 %r32, %r30, %r29, %r31;
|
||||
mov.u32 %r33, %ntid.y;
|
||||
mov.u32 %r34, %ctaid.y;
|
||||
mov.u32 %r35, %tid.y;
|
||||
mad.lo.s32 %r36, %r34, %r33, %r35;
|
||||
shl.b32 %r1, %r32, 1;
|
||||
shl.b32 %r2, %r36, 1;
|
||||
setp.ge.s32 %p1, %r1, %r25;
|
||||
setp.ge.s32 %p2, %r2, %r26;
|
||||
or.pred %p3, %p1, %p2;
|
||||
mov.f32 %f102, 0f00000000;
|
||||
mov.f32 %f103, 0f00000000;
|
||||
mov.f32 %f104, 0f00000000;
|
||||
@%p3 bra $L__BB2_19;
|
||||
|
||||
cvt.s64.s32 %rd3, %r21;
|
||||
add.s32 %r3, %r2, %r28;
|
||||
setp.ge.s32 %p4, %r3, %r24;
|
||||
mul.lo.s32 %r4, %r2, %r25;
|
||||
mul.wide.s32 %rd4, %r3, %r21;
|
||||
add.s32 %r5, %r1, %r27;
|
||||
or.b32 %r38, %r5, %r3;
|
||||
setp.lt.s32 %p5, %r38, 0;
|
||||
mov.u32 %r121, 0;
|
||||
setp.ge.s32 %p6, %r5, %r23;
|
||||
or.pred %p7, %p6, %p5;
|
||||
or.pred %p8, %p4, %p7;
|
||||
@%p8 bra $L__BB2_4;
|
||||
|
||||
add.s32 %r40, %r1, %r4;
|
||||
mul.wide.s32 %rd13, %r40, 4;
|
||||
add.s64 %rd5, %rd2, %rd13;
|
||||
ld.global.u8 %rs1, [%rd5+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB2_4;
|
||||
|
||||
cvt.u32.u16 %r42, %rs1;
|
||||
ld.global.u8 %rs5, [%rd5];
|
||||
cvt.rn.f32.u16 %f31, %rs5;
|
||||
ld.global.u8 %rs6, [%rd5+1];
|
||||
cvt.rn.f32.u16 %f32, %rs6;
|
||||
ld.global.u8 %rs7, [%rd5+2];
|
||||
cvt.rn.f32.u16 %f33, %rs7;
|
||||
fma.rn.f32 %f34, %f31, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f35, %f32, 0f3F1D3C36, %f34;
|
||||
fma.rn.f32 %f36, %f33, 0f3D7DF3B6, %f35;
|
||||
add.f32 %f37, %f36, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r43, %f37;
|
||||
cvt.s64.s32 %rd14, %r5;
|
||||
add.s64 %rd15, %rd4, %rd14;
|
||||
add.s64 %rd16, %rd1, %rd15;
|
||||
ld.global.u8 %r44, [%rd16];
|
||||
mul.lo.s32 %r45, %r43, %r42;
|
||||
xor.b32 %r46, %r42, 255;
|
||||
mad.lo.s32 %r47, %r46, %r44, %r45;
|
||||
mul.hi.s32 %r48, %r47, -2139062143;
|
||||
add.s32 %r49, %r48, %r47;
|
||||
shr.u32 %r50, %r49, 31;
|
||||
shr.u32 %r51, %r49, 7;
|
||||
add.s32 %r52, %r51, %r50;
|
||||
st.global.u8 [%rd16], %r52;
|
||||
fma.rn.f32 %f38, %f31, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f39, %f32, 0fBEAD5CFB, %f38;
|
||||
fma.rn.f32 %f40, %f33, 0f3EE0DED3, %f39;
|
||||
cvt.rn.f32.u16 %f104, %rs1;
|
||||
fma.rn.f32 %f102, %f40, %f104, 0f00000000;
|
||||
fma.rn.f32 %f41, %f31, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f42, %f32, 0fBECC3C9F, %f41;
|
||||
fma.rn.f32 %f43, %f33, 0fBD25119D, %f42;
|
||||
fma.rn.f32 %f103, %f43, %f104, 0f00000000;
|
||||
mov.u32 %r121, 1;
|
||||
|
||||
$L__BB2_4:
|
||||
add.s32 %r7, %r1, 1;
|
||||
setp.ge.s32 %p10, %r7, %r25;
|
||||
@%p10 bra $L__BB2_8;
|
||||
|
||||
add.s32 %r8, %r7, %r27;
|
||||
or.b32 %r53, %r8, %r3;
|
||||
setp.lt.s32 %p12, %r53, 0;
|
||||
setp.ge.s32 %p13, %r8, %r23;
|
||||
or.pred %p14, %p13, %p12;
|
||||
or.pred %p15, %p4, %p14;
|
||||
@%p15 bra $L__BB2_8;
|
||||
|
||||
add.s32 %r54, %r7, %r4;
|
||||
mul.wide.s32 %rd17, %r54, 4;
|
||||
add.s64 %rd6, %rd2, %rd17;
|
||||
ld.global.u8 %rs2, [%rd6+3];
|
||||
setp.eq.s16 %p16, %rs2, 0;
|
||||
@%p16 bra $L__BB2_8;
|
||||
|
||||
cvt.u32.u16 %r55, %rs2;
|
||||
ld.global.u8 %rs8, [%rd6];
|
||||
cvt.rn.f32.u16 %f44, %rs8;
|
||||
ld.global.u8 %rs9, [%rd6+1];
|
||||
cvt.rn.f32.u16 %f45, %rs9;
|
||||
ld.global.u8 %rs10, [%rd6+2];
|
||||
cvt.rn.f32.u16 %f46, %rs10;
|
||||
fma.rn.f32 %f47, %f44, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f48, %f45, 0f3F1D3C36, %f47;
|
||||
fma.rn.f32 %f49, %f46, 0f3D7DF3B6, %f48;
|
||||
add.f32 %f50, %f49, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r56, %f50;
|
||||
cvt.s64.s32 %rd18, %r8;
|
||||
add.s64 %rd19, %rd4, %rd18;
|
||||
add.s64 %rd20, %rd1, %rd19;
|
||||
ld.global.u8 %r57, [%rd20];
|
||||
mul.lo.s32 %r58, %r56, %r55;
|
||||
xor.b32 %r59, %r55, 255;
|
||||
mad.lo.s32 %r60, %r59, %r57, %r58;
|
||||
mul.hi.s32 %r61, %r60, -2139062143;
|
||||
add.s32 %r62, %r61, %r60;
|
||||
shr.u32 %r63, %r62, 31;
|
||||
shr.u32 %r64, %r62, 7;
|
||||
add.s32 %r65, %r64, %r63;
|
||||
st.global.u8 [%rd20], %r65;
|
||||
fma.rn.f32 %f51, %f44, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f52, %f45, 0fBEAD5CFB, %f51;
|
||||
fma.rn.f32 %f53, %f46, 0f3EE0DED3, %f52;
|
||||
cvt.rn.f32.u16 %f54, %rs2;
|
||||
fma.rn.f32 %f102, %f53, %f54, %f102;
|
||||
fma.rn.f32 %f55, %f44, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f56, %f45, 0fBECC3C9F, %f55;
|
||||
fma.rn.f32 %f57, %f46, 0fBD25119D, %f56;
|
||||
fma.rn.f32 %f103, %f57, %f54, %f103;
|
||||
add.f32 %f104, %f104, %f54;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_8:
|
||||
add.s32 %r11, %r2, 1;
|
||||
setp.ge.s32 %p17, %r11, %r26;
|
||||
add.s32 %r12, %r11, %r28;
|
||||
add.s32 %r13, %r4, %r25;
|
||||
cvt.s64.s32 %rd21, %r12;
|
||||
mul.lo.s64 %rd7, %rd21, %rd3;
|
||||
@%p17 bra $L__BB2_12;
|
||||
|
||||
setp.ge.s32 %p18, %r12, %r24;
|
||||
or.b32 %r66, %r5, %r12;
|
||||
setp.lt.s32 %p19, %r66, 0;
|
||||
or.pred %p21, %p6, %p19;
|
||||
or.pred %p22, %p18, %p21;
|
||||
@%p22 bra $L__BB2_12;
|
||||
|
||||
add.s32 %r67, %r1, %r13;
|
||||
mul.wide.s32 %rd22, %r67, 4;
|
||||
add.s64 %rd8, %rd2, %rd22;
|
||||
ld.global.u8 %rs3, [%rd8+3];
|
||||
setp.eq.s16 %p23, %rs3, 0;
|
||||
@%p23 bra $L__BB2_12;
|
||||
|
||||
cvt.u32.u16 %r68, %rs3;
|
||||
ld.global.u8 %rs11, [%rd8];
|
||||
cvt.rn.f32.u16 %f58, %rs11;
|
||||
ld.global.u8 %rs12, [%rd8+1];
|
||||
cvt.rn.f32.u16 %f59, %rs12;
|
||||
ld.global.u8 %rs13, [%rd8+2];
|
||||
cvt.rn.f32.u16 %f60, %rs13;
|
||||
fma.rn.f32 %f61, %f58, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f62, %f59, 0f3F1D3C36, %f61;
|
||||
fma.rn.f32 %f63, %f60, 0f3D7DF3B6, %f62;
|
||||
add.f32 %f64, %f63, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r69, %f64;
|
||||
cvt.s64.s32 %rd23, %r5;
|
||||
add.s64 %rd24, %rd7, %rd23;
|
||||
add.s64 %rd25, %rd1, %rd24;
|
||||
ld.global.u8 %r70, [%rd25];
|
||||
mul.lo.s32 %r71, %r69, %r68;
|
||||
xor.b32 %r72, %r68, 255;
|
||||
mad.lo.s32 %r73, %r72, %r70, %r71;
|
||||
mul.hi.s32 %r74, %r73, -2139062143;
|
||||
add.s32 %r75, %r74, %r73;
|
||||
shr.u32 %r76, %r75, 31;
|
||||
shr.u32 %r77, %r75, 7;
|
||||
add.s32 %r78, %r77, %r76;
|
||||
st.global.u8 [%rd25], %r78;
|
||||
fma.rn.f32 %f65, %f58, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f66, %f59, 0fBEAD5CFB, %f65;
|
||||
fma.rn.f32 %f67, %f60, 0f3EE0DED3, %f66;
|
||||
cvt.rn.f32.u16 %f68, %rs3;
|
||||
fma.rn.f32 %f102, %f67, %f68, %f102;
|
||||
fma.rn.f32 %f69, %f58, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f70, %f59, 0fBECC3C9F, %f69;
|
||||
fma.rn.f32 %f71, %f60, 0fBD25119D, %f70;
|
||||
fma.rn.f32 %f103, %f71, %f68, %f103;
|
||||
add.f32 %f104, %f104, %f68;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_12:
|
||||
or.pred %p26, %p17, %p10;
|
||||
@%p26 bra $L__BB2_16;
|
||||
|
||||
setp.ge.s32 %p27, %r12, %r24;
|
||||
add.s32 %r16, %r7, %r27;
|
||||
or.b32 %r79, %r16, %r12;
|
||||
setp.lt.s32 %p28, %r79, 0;
|
||||
setp.ge.s32 %p29, %r16, %r23;
|
||||
or.pred %p30, %p29, %p28;
|
||||
or.pred %p31, %p27, %p30;
|
||||
@%p31 bra $L__BB2_16;
|
||||
|
||||
add.s32 %r80, %r7, %r13;
|
||||
mul.wide.s32 %rd26, %r80, 4;
|
||||
add.s64 %rd9, %rd2, %rd26;
|
||||
ld.global.u8 %rs4, [%rd9+3];
|
||||
setp.eq.s16 %p32, %rs4, 0;
|
||||
@%p32 bra $L__BB2_16;
|
||||
|
||||
cvt.u32.u16 %r81, %rs4;
|
||||
ld.global.u8 %rs14, [%rd9];
|
||||
cvt.rn.f32.u16 %f72, %rs14;
|
||||
ld.global.u8 %rs15, [%rd9+1];
|
||||
cvt.rn.f32.u16 %f73, %rs15;
|
||||
ld.global.u8 %rs16, [%rd9+2];
|
||||
cvt.rn.f32.u16 %f74, %rs16;
|
||||
fma.rn.f32 %f75, %f72, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f76, %f73, 0f3F1D3C36, %f75;
|
||||
fma.rn.f32 %f77, %f74, 0f3D7DF3B6, %f76;
|
||||
add.f32 %f78, %f77, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r82, %f78;
|
||||
cvt.s64.s32 %rd27, %r16;
|
||||
add.s64 %rd28, %rd7, %rd27;
|
||||
add.s64 %rd29, %rd1, %rd28;
|
||||
ld.global.u8 %r83, [%rd29];
|
||||
mul.lo.s32 %r84, %r82, %r81;
|
||||
xor.b32 %r85, %r81, 255;
|
||||
mad.lo.s32 %r86, %r85, %r83, %r84;
|
||||
mul.hi.s32 %r87, %r86, -2139062143;
|
||||
add.s32 %r88, %r87, %r86;
|
||||
shr.u32 %r89, %r88, 31;
|
||||
shr.u32 %r90, %r88, 7;
|
||||
add.s32 %r91, %r90, %r89;
|
||||
st.global.u8 [%rd29], %r91;
|
||||
fma.rn.f32 %f79, %f72, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f80, %f73, 0fBEAD5CFB, %f79;
|
||||
fma.rn.f32 %f81, %f74, 0f3EE0DED3, %f80;
|
||||
cvt.rn.f32.u16 %f82, %rs4;
|
||||
fma.rn.f32 %f102, %f81, %f82, %f102;
|
||||
fma.rn.f32 %f83, %f72, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f84, %f73, 0fBECC3C9F, %f83;
|
||||
fma.rn.f32 %f85, %f74, 0fBD25119D, %f84;
|
||||
fma.rn.f32 %f103, %f85, %f82, %f103;
|
||||
add.f32 %f104, %f104, %f82;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_16:
|
||||
setp.eq.s32 %p33, %r121, 0;
|
||||
setp.le.f32 %p34, %f104, 0f00000000;
|
||||
or.pred %p35, %p34, %p33;
|
||||
@%p35 bra $L__BB2_19;
|
||||
|
||||
shr.u32 %r92, %r5, 31;
|
||||
add.s32 %r93, %r5, %r92;
|
||||
shr.s32 %r19, %r93, 1;
|
||||
setp.lt.s32 %p36, %r3, -1;
|
||||
setp.lt.s32 %p37, %r5, -1;
|
||||
or.pred %p38, %p37, %p36;
|
||||
and.b32 %r94, %r93, -2;
|
||||
setp.ge.s32 %p39, %r94, %r23;
|
||||
or.pred %p40, %p38, %p39;
|
||||
shr.u32 %r95, %r3, 31;
|
||||
add.s32 %r96, %r3, %r95;
|
||||
shr.s32 %r20, %r96, 1;
|
||||
and.b32 %r97, %r96, -2;
|
||||
setp.ge.s32 %p41, %r97, %r24;
|
||||
or.pred %p42, %p40, %p41;
|
||||
@%p42 bra $L__BB2_19;
|
||||
|
||||
div.rn.f32 %f86, %f102, %f104;
|
||||
add.f32 %f87, %f86, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r98, %f87;
|
||||
div.rn.f32 %f88, %f103, %f104;
|
||||
add.f32 %f89, %f88, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r99, %f89;
|
||||
cvt.rn.f32.s32 %f90, %r121;
|
||||
div.rn.f32 %f91, %f104, %f90;
|
||||
add.f32 %f92, %f91, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r100, %f92;
|
||||
mul.wide.s32 %rd30, %r20, %r22;
|
||||
mul.wide.s32 %rd31, %r19, 2;
|
||||
add.s64 %rd32, %rd30, %rd31;
|
||||
cvta.to.global.u64 %rd33, %rd10;
|
||||
add.s64 %rd34, %rd33, %rd32;
|
||||
ld.global.u8 %r101, [%rd34];
|
||||
mul.lo.s32 %r102, %r100, %r98;
|
||||
mov.u32 %r103, 255;
|
||||
sub.s32 %r104, %r103, %r100;
|
||||
mad.lo.s32 %r105, %r104, %r101, %r102;
|
||||
mul.hi.s32 %r106, %r105, -2139062143;
|
||||
add.s32 %r107, %r106, %r105;
|
||||
shr.u32 %r108, %r107, 31;
|
||||
shr.u32 %r109, %r107, 7;
|
||||
add.s32 %r110, %r109, %r108;
|
||||
st.global.u8 [%rd34], %r110;
|
||||
ld.global.u8 %r111, [%rd34+1];
|
||||
mul.lo.s32 %r112, %r100, %r99;
|
||||
mad.lo.s32 %r113, %r104, %r111, %r112;
|
||||
mul.hi.s32 %r114, %r113, -2139062143;
|
||||
add.s32 %r115, %r114, %r113;
|
||||
shr.u32 %r116, %r115, 31;
|
||||
shr.u32 %r117, %r115, 7;
|
||||
add.s32 %r118, %r117, %r116;
|
||||
st.global.u8 [%rd34+1], %r118;
|
||||
|
||||
$L__BB2_19:
|
||||
ret;
|
||||
|
||||
}
|
||||
|
||||
@@ -652,6 +652,7 @@ impl Encoder for NvencEncoder {
|
||||
pts_ns,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
|
||||
@@ -41,6 +41,12 @@ use std::ptr;
|
||||
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
/// Prebuilt PTX for the cursor-overlay blend kernels (cursor-as-metadata). Source is
|
||||
/// `cursor_blend.cu` beside this file; regenerate with
|
||||
/// `nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx` after editing. JIT'd by the
|
||||
/// driver, so it runs on any Turing-or-newer GPU.
|
||||
const CURSOR_PTX: &[u8] = include_bytes!("cursor_blend.ptx");
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Runtime-loaded NVENC entry table (Linux). Same shape as the Windows backend's `EncodeApi`, minus
|
||||
// the async-event entry points (Windows-only). Resolved once from `libnvidia-encode.so.1` — the two
|
||||
@@ -224,6 +230,8 @@ fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
|
||||
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
|
||||
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
|
||||
// Guarded by the open_video dispatch: a PyroWave session never reaches NVENC.
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +311,12 @@ pub struct NvencCudaEncoder {
|
||||
split_mode: u32,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for one loss.
|
||||
last_rfi_range: Option<(i64, i64)>,
|
||||
/// Cursor-as-metadata GPU blend (loaded lazily on the first frame that carries a cursor, once the
|
||||
/// CUDA context is current). `None` until then or if the module load fails; `cursor_tried` stops
|
||||
/// re-attempting a failed load every frame. `cursor_serial` tracks the uploaded bitmap.
|
||||
cursor: Option<cuda::CursorBlend>,
|
||||
cursor_tried: bool,
|
||||
cursor_serial: u64,
|
||||
}
|
||||
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||
@@ -365,6 +379,9 @@ impl NvencCudaEncoder {
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
pending_anchor: false,
|
||||
cursor: None,
|
||||
cursor_tried: false,
|
||||
cursor_serial: u64::MAX,
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
@@ -522,6 +539,7 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
Codec::Av1 => {}
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
|
||||
// Chroma + bit depth. 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3) engages on a YUV444
|
||||
@@ -549,6 +567,7 @@ impl NvencCudaEncoder {
|
||||
.set_inputPixelBitDepthMinus8(0);
|
||||
}
|
||||
Codec::H264 => {}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +615,7 @@ impl NvencCudaEncoder {
|
||||
av1.matrixCoefficients = mat;
|
||||
av1.colorRange = 0;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +636,7 @@ impl NvencCudaEncoder {
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
|
||||
}
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
}
|
||||
Ok(cfg)
|
||||
@@ -931,6 +952,50 @@ impl Encoder for NvencCudaEncoder {
|
||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||
self.copy_into_slot(buf, slot)?;
|
||||
|
||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
||||
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
||||
// first cursor frame now that the CUDA context is current; any failure degrades to no cursor,
|
||||
// never a dropped frame.
|
||||
if let Some(ov) = &captured.cursor {
|
||||
if !self.cursor_tried {
|
||||
self.cursor_tried = true;
|
||||
match cuda::CursorBlend::new(CURSOR_PTX) {
|
||||
Ok(cb) => self.cursor = Some(cb),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"NVENC (Linux): cursor blend module load failed — cursor not composited"
|
||||
),
|
||||
}
|
||||
}
|
||||
if let Some(cb) = &self.cursor {
|
||||
if self.cursor_serial != ov.serial {
|
||||
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
|
||||
Ok(()) => self.cursor_serial = ov.serial,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "cursor upload failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
let s = &self.ring[slot].surface;
|
||||
// surfW = content width; surfH = the surface's allocated height (matches
|
||||
// `copy_into_slot`'s plane math). Cursor pixels past the content are in cropped
|
||||
// padding rows — harmless.
|
||||
let (w, h) = (self.width, s.height);
|
||||
let r = match self.buffer_fmt {
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
||||
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||
}
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||
}
|
||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
tracing::warn!(error = %format!("{e:#}"), "cursor blend launch failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
||||
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
||||
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
||||
@@ -1014,6 +1079,9 @@ impl Encoder for NvencCudaEncoder {
|
||||
pic.codecPicParams.h264PicParams.seiPayloadArrayCnt = sei.len() as u32;
|
||||
}
|
||||
Codec::Av1 => {}
|
||||
Codec::PyroWave => {
|
||||
unreachable!("PyroWave never opens the direct-NVENC backend")
|
||||
}
|
||||
}
|
||||
}
|
||||
(api().encode_picture)(self.encoder, &mut pic)
|
||||
@@ -1139,6 +1207,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: anchor,
|
||||
chunk_aligned: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1226,6 +1295,7 @@ mod tests {
|
||||
pts_ns: i as u64 * 16_666_667,
|
||||
format: PixelFormat::Nv12,
|
||||
payload: FramePayload::Cuda(buf),
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,6 +1410,7 @@ mod tests {
|
||||
pts_ns: i as u64 * 16_666_667,
|
||||
format: PixelFormat::Yuv444,
|
||||
payload: FramePayload::Cuda(buf),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit_indexed(&frame, i).expect("submit 444");
|
||||
while let Some(_au) = enc.poll().expect("poll") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user