Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e001e54b4 | ||
|
|
f3c0ee47d7 | ||
|
|
69f1db5ea9 | ||
|
|
7331be0a40 | ||
|
|
4bc7eecf05 | ||
|
|
dbc12dedcc | ||
|
|
2dfb7791a2 | ||
|
|
6f54fcdd2d | ||
|
|
c6597cbeb5 | ||
|
|
2cfc82e96c | ||
|
|
e9a209ef61 | ||
|
|
a12f1f092c | ||
|
|
3055e29ebb | ||
|
|
7077b0a0df | ||
|
|
e5453aebb7 | ||
|
|
2c03290a5e | ||
|
|
b6a370a0fd | ||
|
|
7db83445b2 | ||
|
|
5582a6ea51 | ||
|
|
f7b85ec1fd | ||
|
|
327301e012 | ||
|
|
ab4cd06e86 | ||
|
|
3eab1e41df | ||
|
|
62573d2781 | ||
|
|
d383fa6103 | ||
|
|
93608980ae | ||
|
|
1feeff3ca6 | ||
|
|
1ae8b4d4ca | ||
|
|
33ecd8e1a5 | ||
|
|
48565c4e9e | ||
|
|
e9a7373c76 | ||
|
|
f7a8c2013d | ||
|
|
926e2ccbdd | ||
|
|
b8b38d082e | ||
|
|
9979489b56 | ||
|
|
14502769e0 | ||
|
|
db1faef9fb | ||
|
|
442ea12b96 | ||
|
|
1e56705b86 | ||
|
|
365caa23be | ||
|
|
f71bee917b | ||
|
|
6de78213ee | ||
|
|
aa3bcfd0d0 | ||
|
|
6b3c582eb1 | ||
|
|
e08474d96d | ||
|
|
f422ae3e38 | ||
|
|
e38e3c44c9 | ||
|
|
b1ac4d02de | ||
|
|
5f55fa874a | ||
|
|
8af6e2dd02 | ||
|
|
d839f4c2b6 | ||
|
|
0de161e29b | ||
|
|
b297542c4d | ||
|
|
98e040fd01 | ||
|
|
5174a59832 | ||
|
|
c2a6d30d7b | ||
|
|
20de58a78a | ||
|
|
97b2c01ac1 | ||
|
|
29473d6280 | ||
|
|
d63e913f52 |
Generated
+32
-32
@@ -947,7 +947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1036,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2221,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2326,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2897,7 +2897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2936,7 +2936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2960,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2969,7 +2969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2981,7 +2981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2995,11 +2995,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3028,14 +3028,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3058,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3103,7 +3103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3115,7 +3115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3323,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3334,7 +3334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3382,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3402,7 +3402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3434,7 +3434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3519,7 +3519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+90
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.22.3"
|
||||
"version": "0.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2170,6 +2170,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -6238,6 +6283,50 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
|
||||
@@ -401,8 +401,15 @@ private fun buildSettingsRows(
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
s.gamepadForwarding,
|
||||
) { update(s.copy(gamepadForwarding = it)) },
|
||||
choice(
|
||||
"padType", "Controllers", "Controller type",
|
||||
"padType", null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
@@ -43,6 +43,7 @@ data class SettingsOverlay(
|
||||
val mouseMode: MouseMode? = null,
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -76,6 +77,7 @@ data class SettingsOverlay(
|
||||
mouseMode = mouseMode ?: base.mouseMode,
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -110,6 +112,9 @@ data class SettingsOverlay(
|
||||
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
|
||||
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
|
||||
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -136,6 +141,7 @@ data class SettingsOverlay(
|
||||
"mouse_mode" -> copy(mouseMode = null)
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -159,6 +165,7 @@ data class SettingsOverlay(
|
||||
if (mouseMode != null) add("mouse_mode")
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -190,6 +197,7 @@ data class SettingsOverlay(
|
||||
mouseMode?.let { j.put("mouse_mode", it.storedName) }
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -205,7 +213,8 @@ data class SettingsOverlay(
|
||||
private val KNOWN = setOf(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
|
||||
@@ -227,6 +236,7 @@ data class SettingsOverlay(
|
||||
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -34,6 +34,17 @@ data class Settings(
|
||||
val hdrEnabled: Boolean = true,
|
||||
val compositor: Int = 0,
|
||||
val gamepad: Int = 0,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all. Default on — that was the
|
||||
* unconditional behaviour before this became a setting.
|
||||
*
|
||||
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
|
||||
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
|
||||
* there gives the host two controllers for one pair of hands, and games read both. It also
|
||||
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/** 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 decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -216,6 +227,7 @@ class SettingsStore(context: Context) {
|
||||
hdrEnabled = prefs.getBoolean(K_HDR, true),
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -262,6 +274,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_HDR, s.hdrEnabled)
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -291,6 +304,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HDR = "hdr_enabled"
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
|
||||
@@ -818,11 +818,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
|
||||
@Composable
|
||||
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
|
||||
SettingsGroup(footer = "Applies from the next session.") {
|
||||
// The master switch, above everything it governs. Profileable, so it shows in both
|
||||
// scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
ToggleRow(
|
||||
title = "Forward controllers",
|
||||
subtitle = "Send this device's controllers to the host. Turn it off when your " +
|
||||
"controller already reaches the host another way — USB passthrough such as " +
|
||||
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
|
||||
checked = s.gamepadForwarding,
|
||||
field = "gamepad_forwarding",
|
||||
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
|
||||
)
|
||||
SettingDropdown(
|
||||
label = "Controller type",
|
||||
options = GAMEPAD_OPTIONS,
|
||||
selected = s.gamepad,
|
||||
field = "gamepad",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
@@ -852,6 +864,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
|
||||
"trackpads, gyro and haptics directly",
|
||||
checked = s.sc2Capture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
@@ -861,6 +874,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
@@ -1013,6 +1027,7 @@ private fun <T> SettingDropdown(
|
||||
selected: T,
|
||||
field: String? = null,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -1020,18 +1035,25 @@ private fun <T> SettingDropdown(
|
||||
?: options.firstOrNull()?.second.orEmpty()
|
||||
Column {
|
||||
OverrideBadge(field)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = { if (enabled) expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { (value, lbl) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(lbl) },
|
||||
|
||||
@@ -321,7 +321,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
|
||||
@@ -442,7 +444,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// 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
|
||||
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
|
||||
Sc2Capture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var sc2UsbReceiver: BroadcastReceiver? = null
|
||||
if (sc2 != null) {
|
||||
feedback.onHidRaw = sc2::onHidRaw
|
||||
@@ -492,7 +498,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
|
||||
DsCapture(context, router)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
|
||||
@@ -33,7 +33,24 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
private val handle: Long,
|
||||
private val setting: Int,
|
||||
/**
|
||||
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
* default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
* passthrough such as VirtualHere, or a pad plugged into the host itself — where forwarding
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
@@ -123,7 +140,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
if (down) {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
val wasHeld = slot.held
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
@@ -136,7 +155,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (send && forwarding) {
|
||||
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.
|
||||
@@ -186,7 +207,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
if (forwarding) slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -221,24 +242,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
|
||||
/** 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)
|
||||
if (slot != null && forwarding) 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)
|
||||
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
if (slot != null && forwarding) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
@@ -260,7 +283,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// 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)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
@@ -317,7 +340,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
@@ -330,7 +353,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
|
||||
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
|
||||
// Release this controller's feedback bindings (close its lights session / cancel rumble).
|
||||
@@ -342,11 +365,11 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -104,6 +100,7 @@ struct Counters {
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -126,10 +123,9 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
@@ -157,8 +153,10 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -173,21 +171,25 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -196,20 +198,15 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -408,10 +405,11 @@ fn decode_loop(
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -672,7 +672,11 @@ final class SessionModel: ObservableObject {
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -26,6 +26,7 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -323,8 +324,15 @@ struct GamepadSettingsView: View {
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere — so games don't see two of them.",
|
||||
value: $gamepadForwarding),
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
) { gamepads.preferredID = $0 },
|
||||
|
||||
@@ -122,6 +122,10 @@ enum SettingsFields {
|
||||
.init(name: "gamepad", key: DefaultsKey.gamepadType,
|
||||
overlay: \.gamepadType, effective: \.gamepadType)
|
||||
}
|
||||
static var gamepadForwarding: SettingsField<Bool> {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
@@ -181,6 +185,7 @@ extension SettingsView {
|
||||
base.micEnabled = micEnabled
|
||||
base.echoCancel = echoCancel
|
||||
base.gamepadType = gamepadType
|
||||
base.gamepadForwarding = gamepadForwarding
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
base.presentPriority = presentPriority
|
||||
|
||||
@@ -641,6 +641,15 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
// The master switch, above everything it governs. Profileable, so it renders in
|
||||
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
|
||||
described("Sends controllers connected to this device to the host. Turn it off when "
|
||||
+ "your controller already reaches the host another way — USB passthrough such "
|
||||
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
|
||||
+ "two of them.",
|
||||
field: "gamepad_forwarding") {
|
||||
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
|
||||
}
|
||||
// Which physical pad this device forwards, and what its own haptics do, are facts
|
||||
// about THIS device (tier G) — only the virtual pad the host creates is profileable.
|
||||
if !inProfileScope {
|
||||
@@ -659,6 +668,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
}
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
@@ -669,6 +679,7 @@ extension SettingsView {
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
|
||||
@@ -49,6 +49,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
|
||||
@@ -3,28 +3,66 @@ import os
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// reads return silence until enough is buffered (at least the target, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime).
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
///
|
||||
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
|
||||
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
|
||||
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
|
||||
/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one
|
||||
/// audible blip". It is now the same two-stage scheme the Rust clients share
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
/// …and must stay there for this much consumed audio. Long, because a shed is the only thing
|
||||
/// here a listener could notice; it must never fire on a transient.
|
||||
private static let shedSustainMS = 2_000
|
||||
private static let crossfadeMS = 2
|
||||
/// Time constant of the depth average.
|
||||
private static let ewmaTauMS = 1_000
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private var emptyReads = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
|
||||
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
/// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames).
|
||||
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
|
||||
init(capacity: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
perMS = 48 * channels
|
||||
}
|
||||
|
||||
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
|
||||
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
buf[(writeIdx + i) % capacity] = samples[i]
|
||||
}
|
||||
writeIdx += count
|
||||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||||
// Shedding down to 2× prefill costs one audible blip instead.
|
||||
if writeIdx - readIdx > highWater {
|
||||
readIdx = writeIdx - prefill * 2
|
||||
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
|
||||
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
|
||||
defer { lock.unlock() }
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
|
||||
// Depth average, weighted by the callback size so its time constant is independent of the
|
||||
// device quantum.
|
||||
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
|
||||
depthAvg += (Double(available) - depthAvg) * alpha
|
||||
|
||||
if !primed {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
let n = min(available, count)
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
overRun += count
|
||||
if overRun >= Self.shedSustainMS * perMS {
|
||||
overRun = 0
|
||||
shedOneFrame()
|
||||
shedCount += 1
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
}
|
||||
} else {
|
||||
overRun = 0
|
||||
}
|
||||
|
||||
let n = min(writeIdx - readIdx, count)
|
||||
let capacity = buf.count
|
||||
for i in 0..<n {
|
||||
out[i] = buf[(readIdx + i) % capacity]
|
||||
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
|
||||
readIdx += n
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
primed = false // underrun — re-prime before resuming
|
||||
// De-prime only after a RUN of short reads: a single transient drain must not
|
||||
// manufacture a whole target's worth of fresh silence.
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter { primed = false }
|
||||
} else {
|
||||
emptyReads = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
// The tail of what we discard fades out into the head of what survives.
|
||||
for i in 0..<fade {
|
||||
let old = buf[(readIdx + drop - fade + i) % capacity]
|
||||
let new = buf[(readIdx + drop + i) % capacity]
|
||||
let t = Float(i + 1) / Float(fade + 1)
|
||||
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
|
||||
}
|
||||
}
|
||||
readIdx += drop
|
||||
}
|
||||
|
||||
/// Current buffered depth in milliseconds — for the stats overlay and the drain thread's
|
||||
/// periodic log.
|
||||
var bufferedMS: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (writeIdx - readIdx) / max(perMS, 1)
|
||||
}
|
||||
|
||||
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
|
||||
/// a log line describe the same instant. Mirrors what the three Rust clients report.
|
||||
struct Stats {
|
||||
let bufferedMS: Int
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Stats(
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
|
||||
@@ -317,10 +317,10 @@ public final class SessionAudio {
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = self.ring ?? AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
@@ -403,6 +403,7 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -421,6 +422,17 @@ public final class SessionAudio {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,27 @@ public final class GamepadCapture {
|
||||
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||
public var onDisconnectRequest: (() -> Void)?
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
|
||||
/// default true). Off is for a couch whose controller reaches the host another way — USB
|
||||
/// passthrough such as VirtualHere, or a pad plugged into the host itself — where
|
||||
/// forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
|
||||
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
|
||||
/// is the ONLY controller way out of a stream — a session that silently lost its exit
|
||||
/// because a forwarding preference was off would be a worse bug than the one this fixes.
|
||||
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
|
||||
/// open costs the host nothing and blocks no passthrough tool.
|
||||
public let forwarding: Bool
|
||||
|
||||
/// The connection, or nil while forwarding is off — every wire send goes through this, so
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -205,8 +223,8 @@ public final class GamepadCapture {
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
@@ -233,7 +251,7 @@ public final class GamepadCapture {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
wire?.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
@@ -275,7 +293,7 @@ public final class GamepadCapture {
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
@@ -288,7 +306,7 @@ public final class GamepadCapture {
|
||||
Int32(g.rightTrigger.value * 255),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
@@ -302,7 +320,7 @@ public final class GamepadCapture {
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
@@ -365,13 +383,13 @@ public final class GamepadCapture {
|
||||
if lifted {
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
@@ -394,7 +412,7 @@ public final class GamepadCapture {
|
||||
}
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
wire?.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
@@ -432,15 +450,15 @@ public final class GamepadCapture {
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,56 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
//
|
||||
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
|
||||
// 1. the burst below, fired the instant the drop is observed — wins back a lock the system
|
||||
// is willing to return immediately (a transient drop that wasn't Escape at all);
|
||||
// 2. a CLICK into the video while still captured (`onPointerButton`) — the fallback for the
|
||||
// Escape case proper, where the platform declines during the moment right after its own
|
||||
// release gesture and the burst therefore expires having achieved nothing.
|
||||
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
|
||||
// other path would ever ask again, and the capture would spend the rest of its life on the
|
||||
// absolute pointer — clicking correctly, aiming not at all.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -260,7 +310,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -383,6 +433,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -401,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
// …and if we're captured but NOT locked, this click is also the recovery gesture for an
|
||||
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
|
||||
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
|
||||
// and give up while the capture is still wanted. Nothing else would ever re-ask —
|
||||
// setCaptured is the only other requester and a bare Esc never clears `captured` — so
|
||||
// without this the session stays on the absolute path for the rest of the capture:
|
||||
// clicks still land where you aim (absolute positions keep forwarding) but the game
|
||||
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
|
||||
// which is exactly what the platform wants before it will hand the lock back.
|
||||
//
|
||||
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
|
||||
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
|
||||
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
|
||||
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
|
||||
// already being in flight — a pending burst mutes absolute motion, so re-arming one on
|
||||
// every click of a menu the user is still aiming around would freeze the cursor between
|
||||
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
|
||||
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
|
||||
// may have just spent).
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
@@ -693,6 +773,24 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -704,7 +802,83 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -724,7 +898,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -32,6 +32,12 @@ public enum DefaultsKey {
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
public static let gamepadID = "punktfunk.gamepadID"
|
||||
/// Forward this device's controllers to the host at all (default true). Off is for a
|
||||
/// couch whose controller reaches the host another way — USB passthrough such as
|
||||
/// VirtualHere, or a pad plugged into the host — where forwarding as well would give the
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// 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.
|
||||
|
||||
@@ -34,6 +34,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var mouseMode = "capture"
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -93,6 +94,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -140,6 +142,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.mouseMode { s.mouseMode = v }
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -110,6 +110,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var mouseMode: String?
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -151,6 +152,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case mouseMode = "mouse_mode"
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -184,6 +186,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
mouseMode = str(.mouseMode)
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -219,6 +222,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
|
||||
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -271,6 +276,7 @@ public enum OverlayField {
|
||||
case "mouse_mode": overlay.mouseMode = nil
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -306,6 +312,7 @@ public enum OverlayField {
|
||||
case "mouse_mode": return o.mouseMode != nil
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
|
||||
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
|
||||
// one client where the policy is hand-written in a second language rather than shared as code — so
|
||||
// a divergence from the Rust side shows up here rather than as a field report.
|
||||
//
|
||||
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
|
||||
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
|
||||
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once —
|
||||
// its own comment called that "one audible blip".
|
||||
|
||||
#if !os(tvOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRingDriftTests: XCTestCase {
|
||||
private let channels = 2
|
||||
private var perMS: Int { 48 * channels }
|
||||
|
||||
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
|
||||
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
|
||||
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Non-zero so a silent callback is distinguishable from real audio.
|
||||
let producer = [Float](repeating: 0.25, count: want + 8)
|
||||
var carry = 0, peak = 0, final = 0, silent = 0
|
||||
|
||||
for i in 0..<(ms / quantumMS) {
|
||||
carry += want * driftPPM
|
||||
let extra = carry / 1_000_000
|
||||
carry -= extra * 1_000_000
|
||||
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
|
||||
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
// Skip the priming window at the very start.
|
||||
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
|
||||
peak = max(peak, ring.bufferedMS)
|
||||
final = ring.bufferedMS
|
||||
}
|
||||
return (final, peak, silent)
|
||||
}
|
||||
|
||||
/// THE regression: with the host clock running fast, buffered latency must return to target
|
||||
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
|
||||
/// harsher than real hardware (tens of ppm).
|
||||
func testDriftDoesNotRatchetLatencyToTheCeiling() {
|
||||
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
|
||||
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
|
||||
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
|
||||
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
/// its target rather than oscillating prime → dropout → re-prime forever.
|
||||
func testLargeDeviceQuantumStillPlays() {
|
||||
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
|
||||
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
|
||||
}
|
||||
|
||||
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
|
||||
/// de-primes only after a RUN of short reads.
|
||||
func testSingleShortReadDoesNotDeprime() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime well past target.
|
||||
let big = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 },
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+10
-3
@@ -24,8 +24,15 @@ the panel looks and feels native to Gaming Mode.
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
|
||||
@@ -93,7 +100,7 @@ restart is required for an out-of-band install to appear.
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
|
||||
+152
-4
@@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do:
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
``set_settings`` MERGES onto the file: it is shared with the desktop client and the console.
|
||||
* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's
|
||||
device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``)
|
||||
and cached, since enumerating them costs a Vulkan + PipeWire init.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||
@@ -343,6 +347,9 @@ def _flatpak() -> str | None:
|
||||
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
|
||||
# home), so nothing else in this file has to care which one answered.
|
||||
NATIVE_BIN = "punktfunk-client"
|
||||
# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate
|
||||
# this device's GPUs and audio endpoints for the settings pickers.
|
||||
SESSION_BIN = "punktfunk-session"
|
||||
|
||||
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
|
||||
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
|
||||
@@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None:
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _session_argv() -> list[str] | None:
|
||||
"""The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there.
|
||||
|
||||
The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on
|
||||
`punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and
|
||||
shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The
|
||||
flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native
|
||||
install puts them in the same bindir, so the session is the client's sibling.
|
||||
"""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return None
|
||||
if prefix[0] == _flatpak():
|
||||
# `flatpak run --command=<bin> <app>` — the app id must stay LAST.
|
||||
return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]]
|
||||
sibling = Path(prefix[0]).with_name(SESSION_BIN)
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
def _client_is_flatpak() -> bool:
|
||||
"""Is the client this plugin actually drives the FLATPAK one?
|
||||
|
||||
@@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]:
|
||||
"""Split `punktfunk-session --list-audio` into ``(sinks, sources)``.
|
||||
|
||||
Its format is one endpoint per line, ``sink|source<TAB>node.name<TAB>description``. The
|
||||
node.name is what gets STORED (it is the stable id the client resolves against), so a line
|
||||
without one is unusable and dropped; a missing description falls back to the name rather than
|
||||
rendering a picker entry with no label. Anything else on the line is ignored, so an extra
|
||||
trailing column in a future client can't break this.
|
||||
"""
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3 or not parts[1].strip():
|
||||
continue
|
||||
kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
entry = {"name": name, "description": description or name}
|
||||
if kind == "sink":
|
||||
sinks.append(entry)
|
||||
elif kind == "source":
|
||||
sources.append(entry)
|
||||
return sinks, sources
|
||||
|
||||
|
||||
async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]:
|
||||
"""Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when
|
||||
it isn't installed or the call errors/times out.
|
||||
|
||||
Only ever used for the two read-only device enumerations — the launch path goes through the
|
||||
Steam shortcut and the wrapper script, never through here. The timeout is generous because
|
||||
`--list-adapters` initialises Vulkan on a cold flatpak."""
|
||||
prefix = _session_argv()
|
||||
if not prefix:
|
||||
return -1, ""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *session_args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return rc, (out or b"").decode("utf-8", "replace")
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("session %s timed out", " ".join(session_args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("session %s failed", " ".join(session_args))
|
||||
return -1, ""
|
||||
|
||||
|
||||
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||
@@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
_HOSTS_TTL_S = 12.0
|
||||
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||
|
||||
# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading
|
||||
# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices`
|
||||
# clears it for the user who just plugged a headset in.
|
||||
_devices_cache: dict = {"data": None}
|
||||
|
||||
|
||||
def _invalidate_hosts_cache() -> None:
|
||||
_hosts_cache["data"] = None
|
||||
@@ -1044,24 +1132,84 @@ class Plugin:
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad,
|
||||
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
|
||||
"gamepad_forwarding": True, "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
"stats_verbosity": "normal", "show_stats": True,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch.
|
||||
|
||||
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
|
||||
the desktop client and the console's settings screen, and it holds far more keys than
|
||||
this panel models (decoder, GPU, profiles, touch/mouse model…). The panel reads it once
|
||||
when it mounts, so a straight write would post a snapshot that predates anything those
|
||||
other editors stored in the meantime — silently reverting it.
|
||||
"""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
try:
|
||||
on_disk = json.loads(_settings_path().read_text())
|
||||
if not isinstance(on_disk, dict):
|
||||
on_disk = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
on_disk = {} # no file yet (or an unreadable one): this write creates it
|
||||
on_disk.update(settings)
|
||||
_settings_path().write_text(json.dumps(on_disk, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def list_devices(self) -> dict:
|
||||
"""GPUs + audio endpoints for the settings tab's device pickers.
|
||||
|
||||
Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the
|
||||
Decky session: hardware doesn't come and go often enough to justify paying that on every
|
||||
remount of the page, and a stale entry is harmless — a picked device that has since
|
||||
vanished falls back to the OS default in the client anyway. `refresh_devices` clears it.
|
||||
|
||||
Best-effort in the same way every other client call here is: no session binary (an old
|
||||
flatpak that predates the two-binary split, or a native install missing its sibling) just
|
||||
means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as
|
||||
"you have no devices".
|
||||
"""
|
||||
if _devices_cache["data"] is not None:
|
||||
return _devices_cache["data"]
|
||||
|
||||
adapters: list[str] = []
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
rc_a, out_a = await _run_session(["--list-adapters"])
|
||||
if rc_a == 0:
|
||||
adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()]
|
||||
rc_d, out_d = await _run_session(["--list-audio"])
|
||||
if rc_d == 0:
|
||||
sinks, sources = _parse_audio_endpoints(out_d)
|
||||
|
||||
result = {
|
||||
"ok": rc_a == 0 or rc_d == 0,
|
||||
"adapters": adapters,
|
||||
"sinks": sinks,
|
||||
"sources": sources,
|
||||
}
|
||||
# Only a run that actually answered is worth remembering — caching a failure would make
|
||||
# a client installed after the page was first opened stay invisible until a Decky restart.
|
||||
if result["ok"]:
|
||||
_devices_cache["data"] = result
|
||||
return result
|
||||
|
||||
async def refresh_devices(self) -> dict:
|
||||
"""Drop the cached enumeration and read it again (a headset was just plugged in)."""
|
||||
_devices_cache["data"] = None
|
||||
return await self.list_devices()
|
||||
|
||||
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||
|
||||
async def list_hosts(self, probe: bool = True) -> dict:
|
||||
|
||||
@@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{failures} check(s) FAILED")
|
||||
|
||||
@@ -101,24 +101,97 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||
// here would be a dead one. The desktop client's row still edits this same file.
|
||||
inhibit_shortcuts: boolean;
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -185,6 +258,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
|
||||
@@ -334,8 +334,14 @@ const HostsTab: FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
+608
-152
@@ -1,10 +1,59 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
import { RowActions } from "./ui";
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
@@ -17,50 +66,543 @@ const selectShell: CSSProperties = {
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
|
||||
// falls back from when its GPU can't encode it.
|
||||
const CODECS = ["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
hevc: "HEVC (H.265)",
|
||||
h264: "H.264 (AVC)",
|
||||
av1: "AV1",
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
@@ -74,128 +616,42 @@ export const SettingsSection: FC = () => {
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field
|
||||
label="Video codec"
|
||||
description="Preferred stream codec — the host falls back when its GPU can't encode it"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
|
||||
selectedOption={s.codec ?? "auto"}
|
||||
onChange={(o) => patch({ codec: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -156,6 +156,20 @@ mod index {
|
||||
pub fn gamepad(s: &Settings) -> u32 {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|&p| p == s.present_priority)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn smooth_buffer(s: &Settings) -> u32 {
|
||||
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
|
||||
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
||||
@@ -625,12 +639,27 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad") {
|
||||
o.gamepad = Some(values.gamepad.clone());
|
||||
}
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
if touched.has("fullscreen_on_stream") {
|
||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||
}
|
||||
if touched.has("present_priority") {
|
||||
o.present_priority = Some(values.present_priority.clone());
|
||||
}
|
||||
if touched.has("smooth_buffer") {
|
||||
o.smooth_buffer = Some(values.smooth_buffer);
|
||||
}
|
||||
if touched.has("vsync") {
|
||||
o.vsync = Some(values.vsync);
|
||||
}
|
||||
if touched.has("allow_vrr") {
|
||||
o.allow_vrr = Some(values.allow_vrr);
|
||||
}
|
||||
// Resets are not handled here: they clear the field and re-seed their row the moment the
|
||||
// user asks, so by the time this runs the catalog already reflects them and the row is no
|
||||
// longer marked touched.
|
||||
@@ -684,6 +713,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
|
||||
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
|
||||
/// touch/mouse rows.
|
||||
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
|
||||
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
|
||||
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
|
||||
"Each frame shows the moment the display can take it",
|
||||
"Buffers a little to even out network hiccups",
|
||||
];
|
||||
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
|
||||
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
|
||||
/// per frame, and the session's refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
|
||||
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
@@ -1213,6 +1256,50 @@ pub fn show_scoped(
|
||||
row
|
||||
});
|
||||
|
||||
// ---- Display: Presentation ----
|
||||
// The intent pair the Apple and Android clients already carry. The buffer row only
|
||||
// means anything under Smoothness, so it hides itself the rest of the time rather
|
||||
// than sitting there inert.
|
||||
let present_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Prioritize",
|
||||
PRESENT_PRIORITY_CAPTIONS[0],
|
||||
PRESENT_PRIORITY_LABELS,
|
||||
);
|
||||
let buffer_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Smoothness buffer",
|
||||
"Each frame held absorbs one refresh of hiccup and adds one of delay",
|
||||
SMOOTH_BUFFER_LABELS,
|
||||
);
|
||||
{
|
||||
let w = present_row.widget().clone();
|
||||
let buffer = buffer_row.widget().clone();
|
||||
present_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
|
||||
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
|
||||
});
|
||||
}
|
||||
let vsync_row = adw::SwitchRow::builder()
|
||||
.title("V-Sync")
|
||||
.subtitle(
|
||||
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
|
||||
lowest possible delay, at the cost of visible tearing. Not every driver \
|
||||
offers it; the stats overlay names the mode actually in use",
|
||||
)
|
||||
.build();
|
||||
let vrr_row = adw::SwitchRow::builder()
|
||||
.title("Follow variable refresh rate")
|
||||
.subtitle(
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
|
||||
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
|
||||
harmless on a fixed-refresh screen",
|
||||
)
|
||||
.build();
|
||||
|
||||
// ---- Display: Host output ----
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
@@ -1376,6 +1463,17 @@ pub fn show_scoped(
|
||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
|
||||
// instead of silently snapping back to Automatic.
|
||||
// Off = this device's controllers are not sent at all, because they reach the host
|
||||
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
|
||||
// It also stops the session OPENING the pad, which is what frees the device for a
|
||||
// passthrough tool to bind — so the two rows below have nothing to act on while it is
|
||||
// off, and are desensitised to say so.
|
||||
let pad_forward_row = adw::SwitchRow::builder()
|
||||
.title("Forward controllers")
|
||||
.subtitle(
|
||||
"Send this device's controllers to the host — off if it already has them another way",
|
||||
)
|
||||
.build();
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
|
||||
@@ -1444,6 +1542,18 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Both pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Seed from the effective settings for this scope ----
|
||||
{
|
||||
@@ -1454,6 +1564,7 @@ pub fn show_scoped(
|
||||
hz_row.set_selected(index::refresh(s));
|
||||
scale_row.set_selected(index::render_scale(s));
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
@@ -1479,6 +1590,19 @@ pub fn show_scoped(
|
||||
let codec_i = index::codec(s);
|
||||
codec_row.set_selected(codec_i);
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
|
||||
let present_i = index::present_priority(s);
|
||||
present_row.set_selected(present_i);
|
||||
set_row_subtitle(
|
||||
present_row.widget(),
|
||||
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
|
||||
);
|
||||
buffer_row.set_selected(index::smooth_buffer(s));
|
||||
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
|
||||
buffer_row
|
||||
.widget()
|
||||
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
|
||||
vsync_row.set_active(s.vsync);
|
||||
vrr_row.set_active(s.allow_vrr);
|
||||
}
|
||||
|
||||
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||
@@ -1671,6 +1795,26 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
o.gamepad_forwarding.is_some(),
|
||||
gamepad_forwarding
|
||||
);
|
||||
choice!(
|
||||
present_row,
|
||||
"present_priority",
|
||||
o.present_priority.is_some(),
|
||||
index::present_priority
|
||||
);
|
||||
choice!(
|
||||
buffer_row,
|
||||
"smooth_buffer",
|
||||
o.smooth_buffer.is_some(),
|
||||
index::smooth_buffer
|
||||
);
|
||||
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
|
||||
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||
toggle!(
|
||||
@@ -1775,6 +1919,11 @@ pub fn show_scoped(
|
||||
if let (Some(r), false) = (&gpu_row, profile_mode) {
|
||||
quality_group.add(r.widget());
|
||||
}
|
||||
let presentation_group = group("Presentation", "");
|
||||
presentation_group.add(present_row.widget());
|
||||
presentation_group.add(buffer_row.widget());
|
||||
presentation_group.add(&vsync_row);
|
||||
presentation_group.add(&vrr_row);
|
||||
// The one form-level note (deliberately not repeated on every row).
|
||||
let output_group = group(
|
||||
"Host output",
|
||||
@@ -1783,6 +1932,7 @@ pub fn show_scoped(
|
||||
output_group.add(compositor_row.widget());
|
||||
display.add(&resolution_group);
|
||||
display.add(&quality_group);
|
||||
display.add(&presentation_group);
|
||||
display.add(&output_group);
|
||||
|
||||
let input = page("Input", "input-keyboard-symbolic");
|
||||
@@ -1843,6 +1993,10 @@ pub fn show_scoped(
|
||||
controllers_group.add(&row);
|
||||
}
|
||||
}
|
||||
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
|
||||
// which of THIS device's pads goes first: a "Work" profile can decline to forward
|
||||
// controllers to a host that a "Game" profile forwards them to.
|
||||
controllers_group.add(&pad_forward_row);
|
||||
if !profile_mode {
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
@@ -1915,6 +2069,7 @@ pub fn show_scoped(
|
||||
s.auto_wake = wake_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.gamepad_forwarding = pad_forward_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
@@ -1925,6 +2080,14 @@ pub fn show_scoped(
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.present_priority = PRESENT_PRIORITIES
|
||||
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
|
||||
.to_string();
|
||||
// The index IS the value (0 = Automatic).
|
||||
s.smooth_buffer =
|
||||
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
|
||||
s.vsync = vsync_row.is_active();
|
||||
s.allow_vrr = vrr_row.is_active();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
|
||||
|
||||
@@ -169,6 +169,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
|
||||
// Presentation-tier like the rows above: latched at console start, a per-host
|
||||
// profile cannot move it in this mode (the documented P4 gap).
|
||||
present_priority: settings_at_start.present_priority(),
|
||||
vsync: settings_at_start.vsync,
|
||||
allow_vrr: settings_at_start.allow_vrr,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
|
||||
@@ -188,6 +188,12 @@ mod session_main {
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
|
||||
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
|
||||
// reuses one service across launches, so a stream that follows one with it off must put
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -617,6 +623,9 @@ mod session_main {
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
inhibit_shortcuts: settings.inhibit_shortcuts,
|
||||
present_priority: settings.present_priority(),
|
||||
vsync: settings.vsync,
|
||||
allow_vrr: settings.allow_vrr,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
actions.push(
|
||||
icon_btn("Settings", Symbol::Setting)
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Re-base the settings snapshot on the file before the page
|
||||
// renders — this process is not its only writer (see
|
||||
// settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! Settings).
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
use super::{AppCtx, Screen};
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0).
|
||||
@@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!(
|
||||
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
let (c, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
|
||||
// as the hosts page's Settings button (see settings::refresh_snapshot).
|
||||
super::settings::refresh_snapshot(&c);
|
||||
ss.call(Screen::Settings)
|
||||
}
|
||||
});
|
||||
|
||||
let app_card = card(
|
||||
|
||||
@@ -172,6 +172,10 @@ pub(crate) struct Shared {
|
||||
|
||||
pub struct AppCtx {
|
||||
pub(crate) identity: (String, String),
|
||||
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
|
||||
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
|
||||
/// inside every `commit` — this process is not the file's only writer (session resize,
|
||||
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
|
||||
pub(crate) settings: Mutex<Settings>,
|
||||
pub(crate) gamepad: GamepadService,
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
@@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_settings_rev,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
|
||||
Screen::Help => help::help_page(&set_screen),
|
||||
Screen::Pair => component(pair::pair_page, svc),
|
||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||
|
||||
@@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
|
||||
/// Apple and Android clients share, so one profile means the same thing everywhere.
|
||||
const PRESENT_PRIORITIES: &[(&str, &str)] =
|
||||
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
|
||||
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
|
||||
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
|
||||
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -411,7 +424,16 @@ fn commit(
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
|
||||
// effective settings derive from, and the snapshot is not the file — another process
|
||||
// (session resize, console UI, Decky) may have moved a global under us. The historical
|
||||
// rebase fix ("settings saves stop reverting each other") covered the whole-file
|
||||
// writers but missed this arm.
|
||||
let base = {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
*s = Settings::load();
|
||||
s.clone()
|
||||
};
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
|
||||
return; // deleted from under us; the next render falls back to the defaults scope
|
||||
};
|
||||
@@ -425,6 +447,17 @@ fn commit(
|
||||
rev.1.call(rev.0 + 1);
|
||||
}
|
||||
|
||||
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
|
||||
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
|
||||
/// process start and this process is not the file's only writer (a spawned session persists
|
||||
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
|
||||
/// family), so without this the page opens showing values another process already replaced,
|
||||
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
|
||||
/// file in. The field report this fixes: a codec setting that "changed by itself".
|
||||
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
|
||||
*ctx.settings.lock().unwrap() = Settings::load();
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
@@ -445,8 +478,13 @@ struct OverrideFlags {
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
gamepad_forwarding: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
smooth_buffer: bool,
|
||||
vsync: bool,
|
||||
allow_vrr: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
@@ -473,8 +511,13 @@ impl OverrideFlags {
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
gamepad_forwarding: o.gamepad_forwarding.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
smooth_buffer: o.smooth_buffer.is_some(),
|
||||
vsync: o.vsync.is_some(),
|
||||
allow_vrr: o.allow_vrr.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +894,32 @@ pub(crate) fn settings_page(
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
|
||||
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
|
||||
// intent re-renders the section and the row appears/disappears with it.
|
||||
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
|
||||
let present_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
present_names,
|
||||
present_i,
|
||||
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
|
||||
);
|
||||
let smoothing = s.present_priority == "smooth";
|
||||
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
|
||||
let buffer_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
buffer_names,
|
||||
buffer_i,
|
||||
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
|
||||
);
|
||||
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
|
||||
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
|
||||
s.allow_vrr = on
|
||||
});
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -898,6 +967,10 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let pad_forward_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
|
||||
s.gamepad_forwarding = on
|
||||
});
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
@@ -972,6 +1045,21 @@ pub(crate) fn settings_page(
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
// The client log's home — the file every "check the client log" message means, which until
|
||||
// this row had no way in from the UI at all. The folder rather than the file so the rotated
|
||||
// `.old` generation is in reach too.
|
||||
//
|
||||
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
|
||||
// container: handed a path the package redirection keeps from ever existing, it silently
|
||||
// opens the user's Documents folder instead of failing, which is precisely what this button
|
||||
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
|
||||
// comes back wrong, the click does nothing rather than landing somewhere misleading.
|
||||
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
|
||||
let logs_button = button("Open log folder").on_click(|| {
|
||||
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
|
||||
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
|
||||
}
|
||||
});
|
||||
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
|
||||
s.library_enabled = on
|
||||
});
|
||||
@@ -1065,8 +1153,9 @@ pub(crate) fn settings_page(
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
),
|
||||
// Wording shared with the GTK client (its chroma_row) — same setting,
|
||||
// same constraints.
|
||||
// First sentence shared with the GTK client (its chroma_row); the
|
||||
// constraint sentence names the real gate (host: PyroWave || NVENC) —
|
||||
// "where the host can encode it" cost field users the discovery time.
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
@@ -1075,7 +1164,8 @@ pub(crate) fn settings_page(
|
||||
over.enable_444,
|
||||
chroma_toggle,
|
||||
"Full-colour video: crisp small text and thin lines, at more \
|
||||
bandwidth. HEVC only, and only where the host can encode it.",
|
||||
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
|
||||
codec \u{2014} other encoders stream 4:2:0.",
|
||||
),
|
||||
],
|
||||
None,
|
||||
@@ -1105,6 +1195,60 @@ pub(crate) fn settings_page(
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Presentation"),
|
||||
{
|
||||
let mut fields = vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"present_priority",
|
||||
"Prioritize",
|
||||
over.present_priority,
|
||||
present_combo,
|
||||
"Lowest latency shows each frame the moment the display can take \
|
||||
it \u{2014} a network hiccup becomes an occasional repeated or \
|
||||
skipped frame. Smoothness buffers a little to even those out.",
|
||||
)];
|
||||
if smoothing {
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"smooth_buffer",
|
||||
"Smoothness buffer",
|
||||
over.smooth_buffer,
|
||||
buffer_combo,
|
||||
"Frames held back before showing. Each one absorbs about a \
|
||||
refresh of network hiccup and adds a refresh of delay. \
|
||||
Automatic holds two.",
|
||||
));
|
||||
}
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"vsync",
|
||||
"V-Sync",
|
||||
over.vsync,
|
||||
vsync_toggle,
|
||||
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
|
||||
refresh \u{2014} the lowest possible delay, at the cost of visible \
|
||||
tearing. Not every driver offers it; the stats overlay names the \
|
||||
mode actually in use.",
|
||||
));
|
||||
fields.push(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"allow_vrr",
|
||||
"Follow variable refresh rate",
|
||||
over.allow_vrr,
|
||||
vrr_toggle,
|
||||
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
|
||||
the stream instead of on a fixed cadence. Applies to fullscreen \
|
||||
sessions; harmless on a fixed-refresh screen.",
|
||||
));
|
||||
fields
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described_overridable(
|
||||
@@ -1223,6 +1367,23 @@ pub(crate) fn settings_page(
|
||||
"Plug in or pair a controller and it appears here.",
|
||||
)
|
||||
}),
|
||||
// Whether ANY controller is forwarded — profileable, so it renders in
|
||||
// both scopes (a "Work" profile can decline what "Game" forwards),
|
||||
// unlike the device-fact picker below it.
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad_forwarding",
|
||||
"Forward controllers",
|
||||
over.gamepad_forwarding,
|
||||
pad_forward_toggle,
|
||||
"Sends controllers connected to this PC to the host. Turn it off when \
|
||||
your controller already reaches the host another way \u{2014} USB \
|
||||
passthrough such as VirtualHere, or a pad plugged into the host \
|
||||
itself \u{2014} so games don't see two of them. Off, this PC never \
|
||||
opens the controller at all, which is what leaves it free for a \
|
||||
passthrough tool to claim.",
|
||||
)),
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
@@ -1325,7 +1486,16 @@ pub(crate) fn settings_page(
|
||||
"About",
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
vec![
|
||||
about_identity.into(),
|
||||
described_labeled(
|
||||
"Diagnostics",
|
||||
logs_button,
|
||||
"The client log (client.log, plus the session\u{2019}s whole \
|
||||
receive/decode/present trail) \u{2014} attach it to a bug report.",
|
||||
),
|
||||
licenses_button.into(),
|
||||
],
|
||||
None,
|
||||
),
|
||||
),
|
||||
@@ -1727,5 +1897,26 @@ mod tests {
|
||||
let f3 = OverrideFlags::of(Some(&p3));
|
||||
assert!(f3.echo_cancel);
|
||||
assert!(!f3.mic_enabled);
|
||||
|
||||
// The presentation pair, likewise independent: pinning the intent doesn't claim
|
||||
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
|
||||
let mut p4 = StreamProfile::new("t4".to_string());
|
||||
p4.overrides = SettingsOverlay {
|
||||
present_priority: Some("smooth".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f4 = OverrideFlags::of(Some(&p4));
|
||||
assert!(f4.present_priority);
|
||||
assert!(!f4.smooth_buffer);
|
||||
|
||||
// V-Sync and VRR are independent of each other and of the intent pair.
|
||||
let mut p5 = StreamProfile::new("t5".to_string());
|
||||
p5.overrides = SettingsOverlay {
|
||||
vsync: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let f5 = OverrideFlags::of(Some(&p5));
|
||||
assert!(f5.vsync);
|
||||
assert!(!f5.allow_vrr && !f5.present_priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
//!
|
||||
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
|
||||
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
|
||||
//! or hand to Explorer.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
@@ -21,13 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
|
||||
///
|
||||
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
|
||||
/// Anything shown to a user or handed to another process wants that one instead.
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
|
||||
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
|
||||
///
|
||||
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
|
||||
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
|
||||
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
|
||||
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
|
||||
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
|
||||
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
|
||||
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
|
||||
/// directory for real, which is why this only ever showed up in the field.
|
||||
///
|
||||
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
|
||||
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
|
||||
pub(crate) fn real_dir() -> Option<PathBuf> {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
|
||||
}
|
||||
|
||||
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
|
||||
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
|
||||
/// and it is noise in a line a user is meant to read and act on.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
use std::path::{Component, Prefix};
|
||||
|
||||
// Scoped so the borrow ends before the `return p` below can move it.
|
||||
let head = match p.components().next() {
|
||||
Some(Component::Prefix(pre)) => match pre.kind() {
|
||||
// `\\?\C:\…` → `C:\…`
|
||||
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
|
||||
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
|
||||
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
|
||||
// separator logic to the bare `\\` and mangle it.
|
||||
Prefix::VerbatimUNC(server, share) => {
|
||||
let mut unc = std::ffi::OsString::from(r"\\");
|
||||
unc.push(server);
|
||||
unc.push(r"\");
|
||||
unc.push(share);
|
||||
Some(PathBuf::from(unc))
|
||||
}
|
||||
// Already a plain path — nothing to undo.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let Some(mut out) = head else { return p };
|
||||
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
|
||||
out.extend(
|
||||
p.components()
|
||||
.skip(1)
|
||||
.filter(|c| !matches!(c, Component::RootDir)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
|
||||
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
Some(real_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
@@ -96,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
|
||||
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off() {
|
||||
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
|
||||
let p = PathBuf::from(
|
||||
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(
|
||||
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
|
||||
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
|
||||
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
|
||||
#[test]
|
||||
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
|
||||
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
|
||||
#[test]
|
||||
fn plain_path_is_untouched() {
|
||||
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(strip_verbatim(p.clone()), p);
|
||||
}
|
||||
|
||||
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
|
||||
/// carries no verbatim prefix. This is the button's actual precondition.
|
||||
#[test]
|
||||
fn real_dir_is_an_openable_directory() {
|
||||
let Some(dir) = real_dir() else {
|
||||
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
|
||||
};
|
||||
assert!(dir.is_dir(), "{} is not a directory", dir.display());
|
||||
assert!(
|
||||
!dir.to_string_lossy().starts_with(r"\\?\"),
|
||||
"{} kept its verbatim prefix",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
/// connect that silently drops back to the host list.
|
||||
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
(code != 0 && code != -1).then(|| {
|
||||
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||
// Name the log's actual location — "check the client log" without a path is a
|
||||
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
|
||||
let log = crate::logfile::path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "the client log".into());
|
||||
format!(
|
||||
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -301,6 +306,10 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -316,7 +325,8 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -328,6 +338,7 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -335,6 +346,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -350,6 +362,7 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
if !note_cursor_id(cursor, id) {
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -367,9 +399,44 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,9 +168,18 @@ struct PlayerData {
|
||||
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
|
||||
recycle: SyncSender<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
|
||||
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
|
||||
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
|
||||
/// one transient drain manufactured a whole target's worth of fresh silence.
|
||||
policy: punktfunk_core::audio::JitterPolicy,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
channels: usize,
|
||||
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
|
||||
/// client log, so a latency or dropout report had nothing to go on.
|
||||
underruns: u64,
|
||||
sheds: u64,
|
||||
callbacks: u64,
|
||||
}
|
||||
|
||||
fn pw_thread(
|
||||
@@ -223,8 +232,14 @@ fn pw_thread(
|
||||
rx: pcm_rx,
|
||||
recycle: recycle_tx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
policy: punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::PIPEWIRE,
|
||||
channels as u8,
|
||||
),
|
||||
channels,
|
||||
underruns: 0,
|
||||
sheds: 0,
|
||||
callbacks: 0,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
@@ -252,23 +267,29 @@ fn pw_thread(
|
||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||
let want = want_frames * ud.channels;
|
||||
|
||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||
// genuine drain.
|
||||
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||
while ud.ring.len() > target.max(want) + want {
|
||||
ud.ring.pop_front();
|
||||
}
|
||||
if !ud.primed && ud.ring.len() >= target {
|
||||
ud.primed = true;
|
||||
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
|
||||
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
|
||||
// and a hard cap as the backstop.
|
||||
let step = ud.policy.step(ud.ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
ud.sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ud.ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
|
||||
let mut ran_short = false;
|
||||
let n_frames = if let Some(slice) = data.data() {
|
||||
for k in 0..want {
|
||||
let s = if ud.primed {
|
||||
ud.ring.pop_front().unwrap_or(0.0)
|
||||
} else {
|
||||
let s = if step.silence {
|
||||
0.0
|
||||
} else {
|
||||
ud.ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
})
|
||||
};
|
||||
let off = k * 4;
|
||||
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
|
||||
@@ -277,8 +298,21 @@ fn pw_thread(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ud.ring.is_empty() {
|
||||
ud.primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
|
||||
// is never miscounted as an underrun.
|
||||
ud.policy.note_read(ran_short);
|
||||
ud.underruns += u64::from(ran_short);
|
||||
ud.callbacks += 1;
|
||||
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
|
||||
// plane stops being invisible.
|
||||
if ud.callbacks % 2_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = ud.policy.avg_depth_ms(),
|
||||
target_ms = ud.policy.target_ms(),
|
||||
underruns = ud.underruns,
|
||||
drift_sheds = ud.sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//!
|
||||
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
|
||||
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
|
||||
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
|
||||
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
|
||||
//! built-in streaming path is deleted).
|
||||
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
|
||||
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
|
||||
//! so this is now the only WASAPI client ring.
|
||||
//!
|
||||
//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session
|
||||
//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread
|
||||
//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before
|
||||
//! producing, cap the ring so latency stays bounded, re-prime after a real drain.
|
||||
//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI
|
||||
//! render thread pulls whole event-driven quanta on the device clock. The depth policy between
|
||||
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
|
||||
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
|
||||
//! the same way and none of them can ratchet latency upward.
|
||||
//!
|
||||
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated
|
||||
//! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag
|
||||
@@ -250,10 +251,20 @@ fn render_thread(
|
||||
audio_client.start_stream().context("start render stream")?;
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||
let mut primed = false;
|
||||
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
|
||||
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
|
||||
// helper with the other three clients).
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
|
||||
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
|
||||
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
|
||||
// target's worth of fresh silence.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::WASAPI,
|
||||
channels,
|
||||
);
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
@@ -262,9 +273,7 @@ fn render_thread(
|
||||
// Drain everything the pump has queued into the ring, returning each drained
|
||||
// Vec to the pool (a full/closed pool drops it).
|
||||
while let Ok(mut chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk.iter() {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
ring.extend(chunk.iter().copied());
|
||||
chunk.clear();
|
||||
let _ = recycle_tx.try_send(chunk);
|
||||
}
|
||||
@@ -274,28 +283,40 @@ fn render_thread(
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
let want = avail_frames * channels as usize;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
let cap = target.max(want_bytes) + want_bytes;
|
||||
if ring.len() > cap {
|
||||
ring.drain(..ring.len() - cap);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
|
||||
}
|
||||
|
||||
out.clear();
|
||||
out.resize(want_bytes, 0);
|
||||
if primed {
|
||||
let n = ring.len().min(want_bytes);
|
||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||
*dst = b;
|
||||
out.resize(avail_frames * block_align, 0);
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
|
||||
for dst in out.chunks_exact_mut(4) {
|
||||
let s = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
dst.copy_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
|
||||
// never miscounted as an underrun.
|
||||
policy.note_read(ran_short);
|
||||
underruns += u64::from(ran_short);
|
||||
callbacks += 1;
|
||||
if callbacks % 1_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = policy.avg_depth_ms(),
|
||||
target_ms = policy.target_ms(),
|
||||
underruns,
|
||||
drift_sheds = sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
|
||||
@@ -336,6 +336,7 @@ enum Ctl {
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -482,6 +483,26 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
|
||||
/// default on). Off is for a couch whose pad reaches the host another way — a USB
|
||||
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
|
||||
/// where forwarding as well would give the host two pads for one pair of hands.
|
||||
///
|
||||
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
|
||||
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
|
||||
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
|
||||
/// It follows that the escape chord, which only listens on forwarded pads, is not
|
||||
/// available while off; the keyboard chord and the client's own UI still end a session.
|
||||
///
|
||||
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
|
||||
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
|
||||
/// it again, so the pad is free for the whole time a stream is up.
|
||||
///
|
||||
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
|
||||
pub fn set_forwarding(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -721,6 +742,10 @@ struct Worker {
|
||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||
pinned: Option<String>,
|
||||
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
|
||||
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
|
||||
/// being that the hardware stays ungrabbed for a USB passthrough tool.
|
||||
forwarding: bool,
|
||||
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
@@ -815,6 +840,11 @@ impl Worker {
|
||||
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
|
||||
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
|
||||
fn forwarded_ids(&self) -> Vec<u32> {
|
||||
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
|
||||
// free for whatever route the user's controller actually takes to the host.
|
||||
if !self.forwarding {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(key) = &self.pinned {
|
||||
if let Some(id) = self
|
||||
.order
|
||||
@@ -1243,10 +1273,16 @@ impl Worker {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
self.attached = Some(c);
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
set_valve_hidapi(true);
|
||||
|
||||
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
|
||||
// enabling them re-enumerates a Deck's built-in pad with paddles/
|
||||
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
|
||||
// Not with forwarding off: this session opens no slot, and the drivers'
|
||||
// mere enumeration both kills the Deck's trackpad-mouse and is the
|
||||
// opposite of leaving the hardware alone for a passthrough tool.
|
||||
if self.forwarding {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
@@ -1269,6 +1305,31 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::Forwarding(on)) => {
|
||||
if self.forwarding == on {
|
||||
continue;
|
||||
}
|
||||
self.forwarding = on;
|
||||
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
|
||||
|
||||
// Applied live rather than at attach only, so a mid-session flip (an
|
||||
// in-stream settings screen) takes effect on the pad in your hands.
|
||||
//
|
||||
// The Valve HIDAPI drivers are an in-session-only thing (see
|
||||
// set_valve_hidapi), and forwarding off is — for their purpose — not in
|
||||
// session. Order matters and differs by direction: ON must enable them
|
||||
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
|
||||
// identity; OFF must disable them AFTER, so no slot outlives the driver
|
||||
// that opened it.
|
||||
let attached = self.attached.is_some();
|
||||
if on && attached {
|
||||
set_valve_hidapi(true);
|
||||
}
|
||||
self.sync_open();
|
||||
if !on && attached {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1608,6 +1669,7 @@ impl Worker {
|
||||
menu_open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
forwarding: true,
|
||||
kind_override: GamepadPref::Auto,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
|
||||
@@ -982,6 +982,10 @@ mod tests {
|
||||
height: 1440,
|
||||
bitrate_kbps: 55000,
|
||||
codec: "av1".into(),
|
||||
present_priority: "smooth".into(),
|
||||
smooth_buffer: 2,
|
||||
vsync: false,
|
||||
allow_vrr: false,
|
||||
..Default::default()
|
||||
},
|
||||
clipboard: true,
|
||||
|
||||
@@ -74,9 +74,23 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// The presentation cluster — the keys the Apple client already writes into this
|
||||
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
|
||||
/// Android carries the first two). First-class here so a profile authored on any
|
||||
/// client applies on all of them instead of riding `extra` unapplied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub present_priority: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub smooth_buffer: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vsync: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_vrr: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
@@ -142,6 +156,9 @@ impl SettingsOverlay {
|
||||
if let Some(v) = &self.gamepad {
|
||||
s.gamepad = v.clone();
|
||||
}
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -150,6 +167,18 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.fullscreen_on_stream {
|
||||
s.fullscreen_on_stream = v;
|
||||
}
|
||||
if let Some(v) = &self.present_priority {
|
||||
s.present_priority = v.clone();
|
||||
}
|
||||
if let Some(v) = self.smooth_buffer {
|
||||
s.smooth_buffer = v;
|
||||
}
|
||||
if let Some(v) = self.vsync {
|
||||
s.vsync = v;
|
||||
}
|
||||
if let Some(v) = self.allow_vrr {
|
||||
s.allow_vrr = v;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -220,12 +249,27 @@ impl SettingsOverlay {
|
||||
if after.gamepad != before.gamepad {
|
||||
self.gamepad = Some(after.gamepad.clone());
|
||||
}
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||
}
|
||||
if after.present_priority != before.present_priority {
|
||||
self.present_priority = Some(after.present_priority.clone());
|
||||
}
|
||||
if after.smooth_buffer != before.smooth_buffer {
|
||||
self.smooth_buffer = Some(after.smooth_buffer);
|
||||
}
|
||||
if after.vsync != before.vsync {
|
||||
self.vsync = Some(after.vsync);
|
||||
}
|
||||
if after.allow_vrr != before.allow_vrr {
|
||||
self.allow_vrr = Some(after.allow_vrr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||
@@ -257,8 +301,13 @@ impl SettingsOverlay {
|
||||
"invert_scroll" => self.invert_scroll = None,
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
"smooth_buffer" => self.smooth_buffer = None,
|
||||
"vsync" => self.vsync = None,
|
||||
"allow_vrr" => self.allow_vrr = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -433,6 +482,10 @@ mod tests {
|
||||
assert_eq!((out.width, out.height), (1920, 1080));
|
||||
assert_eq!(out.bitrate_kbps, 20000);
|
||||
assert_eq!(out.codec, "hevc");
|
||||
assert!(
|
||||
out.gamepad_forwarding,
|
||||
"default on, and an empty overlay leaves it alone"
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let overlay = SettingsOverlay {
|
||||
@@ -452,9 +505,14 @@ mod tests {
|
||||
invert_scroll: Some(true),
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
present_priority: Some("smooth".into()),
|
||||
smooth_buffer: Some(3),
|
||||
vsync: Some(false),
|
||||
allow_vrr: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!overlay.is_empty());
|
||||
@@ -473,9 +531,14 @@ mod tests {
|
||||
assert!(out.invert_scroll);
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
assert_eq!(out.present_priority, "smooth");
|
||||
assert_eq!(out.smooth_buffer, 3);
|
||||
assert!(!out.vsync);
|
||||
assert!(!out.allow_vrr);
|
||||
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
|
||||
// stays coherent with it.
|
||||
assert!(out.show_stats);
|
||||
@@ -573,6 +636,59 @@ mod tests {
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
/// The presentation cluster is first-class, not `extra` passengers: it applies,
|
||||
/// absorbs, clears, and serialises under the exact keys the Apple client already
|
||||
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
|
||||
/// has to round-trip through every platform, and a mismatched key would be carried
|
||||
/// but never applied.
|
||||
#[test]
|
||||
fn presentation_cluster_is_first_class() {
|
||||
let base = Settings::default();
|
||||
let mut o = SettingsOverlay::default();
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.present_priority = "smooth".into();
|
||||
o.absorb(&before, &after);
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.smooth_buffer = 1;
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
|
||||
assert_eq!(o.smooth_buffer, Some(1));
|
||||
assert!(
|
||||
o.extra.is_empty(),
|
||||
"modelled fields must never land in the passthrough"
|
||||
);
|
||||
let out = o.apply(&base);
|
||||
assert_eq!(
|
||||
out.present_priority(),
|
||||
crate::trust::PresentPriority::Smooth { buffer: 1 }
|
||||
);
|
||||
|
||||
// Serialised under the shared keys, and read back from a foreign client's file.
|
||||
let text = serde_json::to_string(&o).unwrap();
|
||||
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
|
||||
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
|
||||
let from_apple: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
|
||||
assert_eq!(from_apple.smooth_buffer, Some(2));
|
||||
assert_eq!(from_apple.vsync, Some(true));
|
||||
assert_eq!(from_apple.allow_vrr, Some(false));
|
||||
assert!(from_apple.extra.is_empty());
|
||||
|
||||
assert!(o.clear("present_priority"));
|
||||
assert!(o.clear("smooth_buffer"));
|
||||
assert_eq!(o.present_priority, None);
|
||||
assert!(o.is_empty());
|
||||
let mut vrr = from_apple;
|
||||
assert!(vrr.clear("vsync"));
|
||||
assert!(vrr.clear("allow_vrr"));
|
||||
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
|
||||
}
|
||||
|
||||
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||
#[test]
|
||||
fn clear_drops_one_override() {
|
||||
@@ -591,6 +707,29 @@ mod tests {
|
||||
assert!(!o.clear("no_such_field"));
|
||||
}
|
||||
|
||||
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
|
||||
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
|
||||
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
|
||||
/// one every client's reset button sends.
|
||||
#[test]
|
||||
fn gamepad_forwarding_overrides_off_and_resets_back() {
|
||||
let base = Settings::default();
|
||||
assert!(base.gamepad_forwarding, "the shipped default");
|
||||
|
||||
let mut o = SettingsOverlay::default();
|
||||
let mut after = base.clone();
|
||||
after.gamepad_forwarding = false;
|
||||
o.absorb(&base, &after);
|
||||
assert_eq!(o.gamepad_forwarding, Some(false));
|
||||
assert!(!o.apply(&base).gamepad_forwarding);
|
||||
|
||||
assert!(o.clear("gamepad_forwarding"));
|
||||
assert_eq!(o.gamepad_forwarding, None);
|
||||
assert!(o.is_empty());
|
||||
// Back to inheriting: the global's live value, not a remembered false.
|
||||
assert!(o.apply(&base).gamepad_forwarding);
|
||||
}
|
||||
|
||||
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
||||
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
||||
#[test]
|
||||
|
||||
@@ -14,6 +14,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::quic::endpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn config_dir() -> Result<PathBuf> {
|
||||
@@ -787,6 +788,45 @@ impl MouseMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation intent — what the presenter optimizes for
|
||||
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
|
||||
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
|
||||
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
|
||||
/// [`PresentPriority::resolve`], whose rules match the Android reference
|
||||
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
|
||||
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PresentPriority {
|
||||
/// Every frame presents the moment the display can take it; a network hiccup is an
|
||||
/// occasional repeated or skipped frame. The default.
|
||||
Latency,
|
||||
/// A small frame buffer (1–3 frames) evens out network/decode jitter, at the
|
||||
/// buffer's worth of added display latency.
|
||||
Smooth { buffer: u8 },
|
||||
}
|
||||
|
||||
impl PresentPriority {
|
||||
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
|
||||
/// a foreign profile's values mean.
|
||||
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
|
||||
if name == "smooth" {
|
||||
PresentPriority::Smooth {
|
||||
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
|
||||
}
|
||||
} else {
|
||||
PresentPriority::Latency
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
|
||||
pub fn fifo_capacity(self) -> u8 {
|
||||
match self {
|
||||
PresentPriority::Latency => 0,
|
||||
PresentPriority::Smooth { buffer } => buffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -808,6 +848,21 @@ pub struct Settings {
|
||||
/// container `#[serde(default)]`.
|
||||
pub render_scale: f64,
|
||||
pub gamepad: String,
|
||||
/// Forward this device's controllers to the host at all. Default ON — that was the
|
||||
/// unconditional behaviour before this became a setting.
|
||||
///
|
||||
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
|
||||
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
|
||||
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
|
||||
/// hands, and games read both.
|
||||
///
|
||||
/// It is deliberately stronger than "send no input": with it off the client never
|
||||
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
|
||||
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
|
||||
/// navigation in the launcher still opens the active pad, and the session releases it;
|
||||
/// see [`crate::gamepad::GamepadService::set_forwarding`].
|
||||
#[serde(default = "default_true")]
|
||||
pub gamepad_forwarding: bool,
|
||||
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
@@ -874,6 +929,32 @@ pub struct Settings {
|
||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||
#[serde(default = "default_true")]
|
||||
pub hdr_enabled: bool,
|
||||
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
|
||||
/// clients' shared `present_priority` profile key, resolved with
|
||||
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
|
||||
/// unknown reads as latency, so a newer client's future value degrades safely.
|
||||
#[serde(default = "default_present_priority")]
|
||||
pub present_priority: String,
|
||||
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 1–3.
|
||||
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
|
||||
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
|
||||
/// refresh of display latency.
|
||||
#[serde(default)]
|
||||
pub smooth_buffer: u8,
|
||||
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
|
||||
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
|
||||
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
|
||||
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
|
||||
/// key; the desktop default differs from macOS's (`false` there) deliberately —
|
||||
/// sync-off means something different on each platform, the key is the contract.
|
||||
#[serde(default = "default_true")]
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence: prefers the present
|
||||
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
|
||||
/// displays (detection is measured from on-glass timestamps, not queried). The
|
||||
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_vrr: bool,
|
||||
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||
@@ -925,6 +1006,14 @@ pub struct Settings {
|
||||
/// the user will be looking at. `0` = never stored → the 1280×720 default.
|
||||
pub last_window_w: u32,
|
||||
pub last_window_h: u32,
|
||||
/// Settings keys this build doesn't model (a newer client's field), carried through a
|
||||
/// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra`
|
||||
/// pattern extended to the globals. Without it, every whole-file writer of this store
|
||||
/// (two shells, the console settings screen, the session's resize callback, Decky)
|
||||
/// running as an OLDER binary silently drops what a newer one persisted. Empty on
|
||||
/// every existing store, and an empty map serializes to nothing, so files don't churn.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -939,6 +1028,10 @@ fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_present_priority() -> String {
|
||||
"latency".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -970,6 +1063,12 @@ impl Settings {
|
||||
MouseMode::from_name(&self.mouse_mode)
|
||||
}
|
||||
|
||||
/// The presentation intent for this session (the resolved
|
||||
/// `present_priority` × `smooth_buffer` pair).
|
||||
pub fn present_priority(&self) -> PresentPriority {
|
||||
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -994,6 +1093,7 @@ impl Default for Settings {
|
||||
bitrate_kbps: 0,
|
||||
render_scale: 1.0,
|
||||
gamepad: "auto".into(),
|
||||
gamepad_forwarding: true,
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
@@ -1007,6 +1107,10 @@ impl Default for Settings {
|
||||
adapter: String::new(),
|
||||
enable_444: false,
|
||||
hdr_enabled: true,
|
||||
present_priority: "latency".into(),
|
||||
smooth_buffer: 0,
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
show_stats: true,
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
@@ -1018,6 +1122,7 @@ impl Default for Settings {
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1144,6 +1249,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file predating the presentation cluster loads with the shipped
|
||||
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
|
||||
/// resolution rules match the Apple/Android reference: anything but an explicit
|
||||
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
|
||||
#[test]
|
||||
fn settings_presentation_defaults_and_resolution() {
|
||||
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.present_priority, "latency");
|
||||
assert_eq!(s.smooth_buffer, 0);
|
||||
assert!(s.vsync);
|
||||
assert!(s.allow_vrr);
|
||||
assert_eq!(s.present_priority(), PresentPriority::Latency);
|
||||
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 0),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"Automatic resolves to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 3),
|
||||
PresentPriority::Smooth { buffer: 3 }
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("smooth", 9),
|
||||
PresentPriority::Smooth { buffer: 2 },
|
||||
"out-of-range pins to the Automatic resolution"
|
||||
);
|
||||
assert_eq!(
|
||||
PresentPriority::resolve("balanced-from-the-future", 2),
|
||||
PresentPriority::Latency,
|
||||
"unknown intents degrade to latency"
|
||||
);
|
||||
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
|
||||
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
|
||||
}
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
@@ -1192,6 +1334,28 @@ mod tests {
|
||||
assert!(s.echo_cancel);
|
||||
}
|
||||
|
||||
/// A key this build doesn't model (a newer client's setting) survives a load→save
|
||||
/// round trip instead of being dropped by the next whole-file write — the same
|
||||
/// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown
|
||||
/// keys, the flatten map adds nothing, so existing files don't churn.
|
||||
#[test]
|
||||
fn settings_unknown_keys_survive_round_trip() {
|
||||
let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#;
|
||||
let s: Settings = serde_json::from_str(newer).unwrap();
|
||||
assert_eq!((s.width, s.height), (1920, 1080));
|
||||
assert_eq!(
|
||||
s.extra.get("frob_mode").and_then(|v| v.as_str()),
|
||||
Some("fancy")
|
||||
);
|
||||
let out = serde_json::to_string(&s).unwrap();
|
||||
assert!(out.contains(r#""frob_mode":"fancy""#), "{out}");
|
||||
assert!(out.contains(r#""frob_level":3"#), "{out}");
|
||||
// No unknown keys → no artifact of the passthrough field in the file.
|
||||
let plain = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert!(!plain.contains("extra"), "{plain}");
|
||||
assert!(!plain.contains("frob"), "{plain}");
|
||||
}
|
||||
|
||||
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||
|
||||
@@ -26,9 +26,14 @@ enum RowId {
|
||||
Decoder,
|
||||
Hdr,
|
||||
Chroma444,
|
||||
PresentPriority,
|
||||
SmoothBuffer,
|
||||
Vsync,
|
||||
AllowVrr,
|
||||
Audio,
|
||||
Mic,
|
||||
EchoCancel,
|
||||
PadForward,
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
@@ -46,7 +51,7 @@ enum RowId {
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
const ROWS: [RowId; 22] = [
|
||||
const ROWS: [RowId; 27] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
@@ -56,9 +61,14 @@ const ROWS: [RowId; 22] = [
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::EchoCancel,
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
@@ -117,6 +127,17 @@ const DECODERS: [(&str, &str); 4] = [
|
||||
("software", "Software"),
|
||||
];
|
||||
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
|
||||
/// Presentation intent — the `present_priority` key shared with the Apple and Android
|
||||
/// clients, so one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [(&str, &str); 2] =
|
||||
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
|
||||
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
|
||||
(0, "Automatic"),
|
||||
(1, "1 frame"),
|
||||
(2, "2 frames"),
|
||||
(3, "3 frames"),
|
||||
];
|
||||
const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("auto", "Automatic"),
|
||||
("xbox360", "Xbox 360"),
|
||||
@@ -222,9 +243,18 @@ impl SettingsScreen {
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
let s = &ctx.settings;
|
||||
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
|
||||
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
|
||||
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
||||
// move everything under the cursor).
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
@@ -279,6 +309,22 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
RowId::PresentPriority => (
|
||||
Some("Presentation"),
|
||||
"Prioritize",
|
||||
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
|
||||
),
|
||||
RowId::SmoothBuffer => (
|
||||
None,
|
||||
"Smoothness buffer",
|
||||
SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.find(|(v, _)| *v == s.smooth_buffer)
|
||||
.map_or("Automatic", |(_, l)| l)
|
||||
.into(),
|
||||
),
|
||||
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
|
||||
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
"Audio channels",
|
||||
@@ -290,8 +336,13 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
|
||||
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
|
||||
RowId::Pad => (
|
||||
RowId::PadForward => (
|
||||
Some("Controller"),
|
||||
"Forward controllers",
|
||||
on_off(s.gamepad_forwarding).into(),
|
||||
),
|
||||
RowId::Pad => (
|
||||
None,
|
||||
"Use controller",
|
||||
if s.forward_pad.is_empty() {
|
||||
"Automatic".into()
|
||||
@@ -368,6 +419,24 @@ fn detail(id: RowId) -> &'static str {
|
||||
Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \
|
||||
stream 4:2:0 and the session falls back silently."
|
||||
}
|
||||
RowId::PresentPriority => {
|
||||
"Lowest latency shows each frame the moment the display can take it — a \
|
||||
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
|
||||
buffers a little to even those out."
|
||||
}
|
||||
RowId::SmoothBuffer => {
|
||||
"Frames held back before showing. Each one absorbs about a refresh of network \
|
||||
hiccup and adds a refresh of delay. Automatic holds two."
|
||||
}
|
||||
RowId::Vsync => {
|
||||
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
|
||||
possible delay, at the cost of visible tearing. Not every driver offers it; \
|
||||
the stats overlay names the mode actually in use."
|
||||
}
|
||||
RowId::AllowVrr => {
|
||||
"On a VRR screen, let the panel refresh in step with the stream instead of on \
|
||||
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => {
|
||||
"Send this device's microphone to the host's virtual mic. \
|
||||
@@ -377,6 +446,11 @@ fn detail(id: RowId) -> &'static str {
|
||||
"Stops the host's audio, playing from this device's speakers, being picked up \
|
||||
and sent back. Turn it off if your microphone already runs its own processing."
|
||||
}
|
||||
RowId::PadForward => {
|
||||
"Send controllers connected to this device to the host. Turn it off when your \
|
||||
controller already reaches the host another way — USB passthrough such as \
|
||||
VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
}
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::Touch => {
|
||||
@@ -463,6 +537,27 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
|
||||
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
|
||||
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
|
||||
RowId::PresentPriority => {
|
||||
let cur = PRESENT_PRIORITIES
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.present_priority);
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
.iter()
|
||||
.position(|(v, _)| *v == s.smooth_buffer);
|
||||
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
|
||||
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
|
||||
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
|
||||
RowId::Audio => {
|
||||
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
|
||||
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
|
||||
@@ -476,7 +571,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
None
|
||||
}
|
||||
}
|
||||
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
|
||||
RowId::Pad => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
// Automatic first, then every connected pad by stable key.
|
||||
let keys: Vec<String> = std::iter::once(String::new())
|
||||
.chain(ctx.pads.iter().map(|p| p.key.clone()))
|
||||
@@ -484,7 +583,12 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let cur = keys.iter().position(|c| *c == s.forward_pad);
|
||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||
}
|
||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||
RowId::PadType => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
@@ -648,6 +752,45 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
|
||||
@@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where desktop audio should be audible — which decides the render endpoint the loopback captures.
|
||||
///
|
||||
/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`,
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env`
|
||||
/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an
|
||||
/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when
|
||||
/// the only way to express it is an undocumented environment variable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AudioOutputMode {
|
||||
/// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not
|
||||
/// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry
|
||||
/// the mix without narrowing it — otherwise real hardware wins anyway.
|
||||
#[default]
|
||||
ClientOnly,
|
||||
/// Prefer real hardware: audio plays on the host as well as the client. The old
|
||||
/// `PUNKTFUNK_HOST_AUDIO=1`.
|
||||
HostAndClient,
|
||||
/// Touch nothing — capture whatever the operator's own default playback device is, and never
|
||||
/// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`.
|
||||
FollowDefault,
|
||||
}
|
||||
|
||||
impl AudioOutputMode {
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default`
|
||||
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
|
||||
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
|
||||
fn from_env() -> AudioOutputMode {
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
|
||||
if !raw.trim().is_empty() {
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
return AudioOutputMode::FollowDefault;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() {
|
||||
return AudioOutputMode::HostAndClient;
|
||||
}
|
||||
AudioOutputMode::ClientOnly
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<AudioOutputMode> {
|
||||
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"client_only" | "client" => Some(AudioOutputMode::ClientOnly),
|
||||
"host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient),
|
||||
"follow_default" | "follow" => Some(AudioOutputMode::FollowDefault),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AudioOutputMode::ClientOnly => "client_only",
|
||||
AudioOutputMode::HostAndClient => "host_and_client",
|
||||
AudioOutputMode::FollowDefault => "follow_default",
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback plan should prefer real hardware over a silent sink.
|
||||
pub fn prefers_host_hardware(self) -> bool {
|
||||
matches!(self, AudioOutputMode::HostAndClient)
|
||||
}
|
||||
|
||||
/// Leave the operator's default playback/recording devices completely alone.
|
||||
pub fn keeps_default(self) -> bool {
|
||||
matches!(self, AudioOutputMode::FollowDefault)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
@@ -99,6 +175,24 @@ pub struct HostConfig {
|
||||
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
|
||||
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
|
||||
pub chacha20: bool,
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which
|
||||
/// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`).
|
||||
///
|
||||
/// A first-class setting because the 2026-08-03 field report needed one: the default
|
||||
/// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual
|
||||
/// endpoint for 25 sessions, and the only way to change it was an undocumented environment
|
||||
/// variable. See [`AudioOutputMode`].
|
||||
pub audio_output_mode: AudioOutputMode,
|
||||
/// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default
|
||||
/// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and
|
||||
/// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it
|
||||
/// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than
|
||||
/// silently downgrading someone's audio.
|
||||
pub audio_quality: Option<String>,
|
||||
/// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None`
|
||||
/// (the default) = automatic: sent only to a client that asked for it, and only while the link
|
||||
/// is actually losing packets.
|
||||
pub audio_redundancy: Option<bool>,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a
|
||||
@@ -246,6 +340,9 @@ impl HostConfig {
|
||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
audio_output_mode: AudioOutputMode::from_env(),
|
||||
audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()),
|
||||
audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
// Default ON while the interval-stutter field program runs (see the field doc).
|
||||
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
|
||||
@@ -348,4 +445,50 @@ mod tests {
|
||||
// An invalid rate stays invalid rather than being laundered into a real one.
|
||||
assert_eq!(c.game_fps(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_output_mode_parses_its_spellings() {
|
||||
for (s, want) in [
|
||||
("client_only", AudioOutputMode::ClientOnly),
|
||||
("client-only", AudioOutputMode::ClientOnly),
|
||||
(" CLIENT ", AudioOutputMode::ClientOnly),
|
||||
("host_and_client", AudioOutputMode::HostAndClient),
|
||||
("both", AudioOutputMode::HostAndClient),
|
||||
("follow_default", AudioOutputMode::FollowDefault),
|
||||
("follow", AudioOutputMode::FollowDefault),
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}");
|
||||
}
|
||||
// Unknown spellings are rejected so the caller can say so, not silently re-routed.
|
||||
for s in ["", "silent", "off", "true"] {
|
||||
assert_eq!(AudioOutputMode::parse(s), None, "{s:?}");
|
||||
}
|
||||
// Round-trip through the canonical spelling.
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// The two predicates are what the wiring plan and the capture loop actually branch on, and
|
||||
/// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different
|
||||
/// promises, and conflating them would either silence the host or stomp the operator's devices.
|
||||
#[test]
|
||||
fn audio_output_mode_predicates_are_disjoint() {
|
||||
assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly);
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}");
|
||||
}
|
||||
assert!(AudioOutputMode::HostAndClient.prefers_host_hardware());
|
||||
assert!(AudioOutputMode::FollowDefault.keeps_default());
|
||||
assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware());
|
||||
assert!(!AudioOutputMode::ClientOnly.keeps_default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Playback {
|
||||
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
|
||||
/// silent, which is the whole point of the delay.
|
||||
starts: Instant,
|
||||
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
|
||||
ends: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
/// `Some(window)` while playing.
|
||||
playing: Option<Playback>,
|
||||
replay_ms: u16,
|
||||
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
|
||||
/// upload since forever and, until now, never acted on: the effect started immediately and
|
||||
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
|
||||
/// Wine does this routinely) fired early AND finished early by the same amount.
|
||||
delay_ms: u16,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
|
||||
/// rumble (or until stopped, when the length is 0).
|
||||
///
|
||||
/// `replay.length` is measured from the END of the delay, not from the play command, so the
|
||||
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
|
||||
/// purely so this is testable — the handler itself needs a live uinput fd.
|
||||
fn window(&self, at: Instant) -> Playback {
|
||||
let starts = at + Duration::from_millis(self.delay_ms as u64);
|
||||
Playback {
|
||||
starts,
|
||||
ends: (self.replay_ms > 0)
|
||||
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||
@@ -299,17 +331,29 @@ impl FfState {
|
||||
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
|
||||
let plane_stale = quiet_since(self.last_activity);
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
let Some(deadline) = e.playing else { continue };
|
||||
match deadline {
|
||||
let Some(p) = e.playing else { continue };
|
||||
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
|
||||
// abandoned-effect force-off — it has not had its turn yet.
|
||||
if now < p.starts {
|
||||
continue;
|
||||
}
|
||||
match p.ends {
|
||||
Some(d) if now >= d => e.playing = None,
|
||||
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||
None if stale => {
|
||||
//
|
||||
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
|
||||
// the plane quiet: the play command is itself the last activity, so an effect with
|
||||
// a `replay.delay` longer than the window would otherwise be force-stopped the
|
||||
// instant it finally started — silent the whole time it waited, then killed on its
|
||||
// first contributing tick.
|
||||
None if plane_stale && quiet_since(p.starts) => {
|
||||
tracing::info!(
|
||||
strong = e.strong,
|
||||
weak = e.weak,
|
||||
@@ -544,10 +588,12 @@ impl VirtualPad {
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
slot.delay_ms = e.replay_delay;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
@@ -574,14 +620,7 @@ impl VirtualPad {
|
||||
(EV_FF, code) => {
|
||||
self.ff.note_activity();
|
||||
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -669,6 +708,11 @@ impl GamepadManager {
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the uinput node would outlive the controller. The swept
|
||||
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
|
||||
// rumble internally); if that ever changes, consume it like the other two backends do.
|
||||
self.slots.reap();
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
@@ -797,15 +841,34 @@ mod ff_state_tests {
|
||||
ff
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, until explicitly stopped.
|
||||
fn playing(at: Instant) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, for `len`.
|
||||
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: Some(at + len),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
// Playing since before the window: "abandoned" means audible AND unattended, so an
|
||||
// effect that only just started is not a candidate however stale the plane is.
|
||||
playing: playing(now - Duration::from_millis(2600)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let now = Instant::now();
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||
@@ -820,8 +883,9 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x4000,
|
||||
weak: 0,
|
||||
playing: Some(Some(now + Duration::from_secs(10))),
|
||||
playing: playing_for(now, Duration::from_secs(10)),
|
||||
replay_ms: 10_000,
|
||||
delay_ms: 0,
|
||||
});
|
||||
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||
// the contract (a real pad honors it too), so it keeps playing…
|
||||
@@ -837,26 +901,135 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now - Duration::from_millis(3000)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
ff.last_activity = now - Duration::from_millis(3000);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||
ff.last_activity = now;
|
||||
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||
ff.effects.get_mut(&0).unwrap().playing = playing(now);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
}
|
||||
|
||||
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
|
||||
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
|
||||
/// started early and finished early — DirectInput under Wine schedules these routinely.
|
||||
#[test]
|
||||
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_millis(500);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback {
|
||||
starts,
|
||||
ends: Some(starts + Duration::from_secs(1)),
|
||||
}),
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
});
|
||||
// Inside the delay: armed but silent.
|
||||
assert_eq!(ff.mix(now, IDLE), None);
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
|
||||
// Delay elapsed: it plays.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(501), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
|
||||
// And ends at delay + length, not at length.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(1600), IDLE),
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The window a play opens, straight from the uploaded fields — this is the half that reads
|
||||
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
|
||||
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
|
||||
#[test]
|
||||
fn window_offsets_the_whole_playback_by_replay_delay() {
|
||||
let at = Instant::now();
|
||||
|
||||
let delayed = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
};
|
||||
let w = delayed.window(at);
|
||||
assert_eq!(
|
||||
w.starts,
|
||||
at + Duration::from_millis(500),
|
||||
"delay defers the start"
|
||||
);
|
||||
assert_eq!(
|
||||
w.ends,
|
||||
Some(at + Duration::from_millis(1500)),
|
||||
"length runs from the END of the delay, so the effect keeps its full second"
|
||||
);
|
||||
|
||||
// No delay: starts immediately, unchanged from before.
|
||||
let plain = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 0,
|
||||
};
|
||||
let w = plain.window(at);
|
||||
assert_eq!(w.starts, at);
|
||||
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
|
||||
|
||||
// Length 0 = until stopped, but the delay still applies.
|
||||
let infinite = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 250,
|
||||
};
|
||||
let w = infinite.window(at);
|
||||
assert_eq!(w.starts, at + Duration::from_millis(250));
|
||||
assert_eq!(w.ends, None);
|
||||
}
|
||||
|
||||
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
|
||||
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
|
||||
#[test]
|
||||
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_secs(5);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback { starts, ends: None }),
|
||||
replay_ms: 0,
|
||||
delay_ms: 5000,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(60); // long stale
|
||||
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
|
||||
// It still plays when its delay elapses.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(5001), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_watchdog_never_cuts() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(600);
|
||||
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||
|
||||
@@ -62,15 +62,30 @@ impl<P> PadSlots<P> {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
|
||||
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
|
||||
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
|
||||
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
|
||||
/// clear for a few frames and returns re-arms nothing.
|
||||
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
|
||||
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
|
||||
/// per-index sibling state; an index another manager owns is `None` here, so it is never
|
||||
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
|
||||
/// frames and returns re-arms nothing.
|
||||
///
|
||||
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
|
||||
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
|
||||
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
|
||||
/// called `sweep` would keep the detached pad alive for the rest of the session.
|
||||
pub fn sweep(&mut self, active_mask: u16) -> u16 {
|
||||
self.sweep_at(active_mask, Instant::now())
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
|
||||
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
|
||||
///
|
||||
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
|
||||
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
|
||||
/// That is what makes it safe to call from a hot pump loop.
|
||||
pub fn reap(&mut self) -> u16 {
|
||||
self.reap_at(Instant::now())
|
||||
}
|
||||
|
||||
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
|
||||
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
|
||||
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
|
||||
@@ -81,26 +96,37 @@ impl<P> PadSlots<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
|
||||
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
|
||||
/// each slot's clock from the mask, then reap whatever has already run out.
|
||||
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
for i in 0..MAX_PADS {
|
||||
if active_mask & (1 << i) != 0 {
|
||||
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
|
||||
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
|
||||
self.inactive_since[i] = Some(now); // newly inactive — start the grace
|
||||
}
|
||||
}
|
||||
self.reap_at(now)
|
||||
}
|
||||
|
||||
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
|
||||
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
|
||||
/// and cannot be dropped here.
|
||||
fn reap_at(&mut self, now: Instant) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(since) = self.inactive_since[i] else {
|
||||
continue; // active, or never went clear — nothing to complete
|
||||
};
|
||||
if self.pads[i].is_none() {
|
||||
self.inactive_since[i] = None; // the slot went away by some other route
|
||||
continue;
|
||||
}
|
||||
if slot.is_none() {
|
||||
continue;
|
||||
}
|
||||
match self.inactive_since[i] {
|
||||
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
|
||||
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
*slot = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
Some(_) => {} // inside the grace — hold
|
||||
if now.duration_since(since) >= SWEEP_GRACE {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
self.pads[i] = None;
|
||||
self.inactive_since[i] = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
}
|
||||
swept
|
||||
@@ -161,6 +187,56 @@ mod tests {
|
||||
PadSlots::new("Test", "test pad", "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_frame_plus_a_reap_completes_the_unplug() {
|
||||
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
|
||||
// with no further frame. Before the arm/reap split the pad survived here forever.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(2, |i| Ok(i as u32)));
|
||||
assert_eq!(
|
||||
s.sweep(0b0),
|
||||
0,
|
||||
"a frame arms the grace but cannot itself drop"
|
||||
);
|
||||
assert!(s.get(2).is_some());
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
|
||||
assert!(s.get(2).is_none());
|
||||
assert_eq!(s.reap(), 0, "nothing left to reap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
|
||||
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
|
||||
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
for _ in 0..10 {
|
||||
assert_eq!(s.reap(), 0);
|
||||
s.expire_grace();
|
||||
}
|
||||
assert!(
|
||||
s.get(0).is_some(),
|
||||
"reap dropped a pad that never went inactive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
|
||||
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
|
||||
// comes back must not churn a PnP devnode.
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |i| Ok(i as u32)));
|
||||
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
|
||||
for _ in 0..5 {
|
||||
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
|
||||
}
|
||||
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
|
||||
s.expire_grace();
|
||||
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
|
||||
assert!(s.get(0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_once_and_reports_freshness() {
|
||||
let mut s = slots();
|
||||
|
||||
@@ -250,11 +250,19 @@ impl DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
|
||||
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
|
||||
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
|
||||
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
|
||||
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
|
||||
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
|
||||
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
|
||||
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
ly: to_u8(ly.saturating_neg()),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
ry: to_u8(ry.saturating_neg()),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
@@ -783,6 +791,29 @@ mod tests {
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
|
||||
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
|
||||
/// sub-deadzone tilt. Extremes must stay exact either way.
|
||||
#[test]
|
||||
fn centred_sticks_encode_as_neutral_on_every_axis() {
|
||||
let n = DsState::neutral();
|
||||
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
|
||||
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
|
||||
|
||||
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
|
||||
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
|
||||
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
|
||||
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
|
||||
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
|
||||
|
||||
// X keeps its existing mapping.
|
||||
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
|
||||
assert_eq!((right.lx, right.rx), (255, 255));
|
||||
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
|
||||
assert_eq!((left.lx, left.rx), (0, 0));
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
|
||||
@@ -183,8 +183,9 @@ impl SteamState {
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
|
||||
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
|
||||
/// ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
@@ -200,8 +201,8 @@ impl SteamState {
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
|
||||
///
|
||||
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
|
||||
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
|
||||
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
|
||||
///
|
||||
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
|
||||
/// ends against this: `32767 >> 7 == 255`.
|
||||
fn trigger_u16(v: u8) -> u16 {
|
||||
((v as u32 * 32767) / 255) as u16
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
|
||||
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
|
||||
// already has a graceful answer to. Reporting the true length lets its own validation
|
||||
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
|
||||
// documented behaviour for a reply it does not like.
|
||||
let len = bytes.len().min(21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
@@ -704,7 +722,7 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
@@ -730,6 +748,30 @@ mod tests {
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
|
||||
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
|
||||
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
|
||||
#[test]
|
||||
fn empty_serial_reply_does_not_panic() {
|
||||
let r = serial_reply("");
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(
|
||||
r[2], 0,
|
||||
"length the kernel will reject, rather than a panic"
|
||||
);
|
||||
|
||||
// Normal and over-long serials still behave.
|
||||
let r = serial_reply("ABC123");
|
||||
assert_eq!(r[2], 6);
|
||||
assert_eq!(&r[4..10], b"ABC123");
|
||||
let long = "X".repeat(40);
|
||||
assert_eq!(
|
||||
serial_reply(&long)[2],
|
||||
21,
|
||||
"clamped to the protocol maximum"
|
||||
);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
|
||||
@@ -159,6 +159,22 @@ impl OverflowWarn {
|
||||
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
|
||||
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
|
||||
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
|
||||
///
|
||||
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
|
||||
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
|
||||
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
|
||||
/// effect longer than this window is cut in half here. The uinput path
|
||||
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
|
||||
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
|
||||
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
|
||||
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
|
||||
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
|
||||
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
|
||||
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
|
||||
/// HD-rumble decays faster than this window regardless.
|
||||
///
|
||||
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
|
||||
/// titles actually hit; the hatch below exists for exactly that experiment.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
|
||||
@@ -217,13 +233,10 @@ impl<B: PadProto> UhidManager<B> {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump` tick — this frame is the only one the producer sends).
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
self.reset_swept(swept);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
@@ -282,6 +295,12 @@ impl<B: PadProto> UhidManager<B> {
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
// Finish any unplug whose removal frame only armed the grace. The producer emits that
|
||||
// frame exactly once, so without this a detached pad — the single-pad session being the
|
||||
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
|
||||
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
@@ -360,6 +379,18 @@ impl<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
|
||||
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
|
||||
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
|
||||
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
|
||||
/// (re)connect starts from scratch and is always forwarded.
|
||||
fn reset_pad(&mut self, idx: usize) {
|
||||
@@ -494,18 +525,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_frame_never_recreates_the_pad_it_swept() {
|
||||
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
|
||||
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
|
||||
// the bit still being set — so the teardown has to finish on the periodic pump. The
|
||||
// previous version of this test hand-fed a SECOND removal frame, which is what let the
|
||||
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(1, 0b10, 0));
|
||||
assert!(m.slots.get(1).is_some());
|
||||
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
|
||||
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
|
||||
// The one removal frame: arms the devnode-churn grace, drops nothing.
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
|
||||
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
|
||||
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_some(),
|
||||
"a tick inside the grace dropped it"
|
||||
);
|
||||
// Grace elapsed: the next tick completes the unplug, with no further frame.
|
||||
m.slots.expire_grace();
|
||||
m.pump(|_, _, _| {}, |_| {});
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"the pump tick never completed the unplug"
|
||||
);
|
||||
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_none());
|
||||
assert!(
|
||||
m.slots.get(1).is_none(),
|
||||
"a cleared-mask frame recreated the pad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -551,10 +600,15 @@ mod tests {
|
||||
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again.
|
||||
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
|
||||
// on a PUMP tick, not on a second frame — that is all production ever sends.
|
||||
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
|
||||
m.slots.expire_grace();
|
||||
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
|
||||
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
|
||||
assert!(
|
||||
m.slots.get(0).is_none(),
|
||||
"the pump tick completed the unplug"
|
||||
);
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
|
||||
@@ -318,14 +318,10 @@ impl GamepadManager {
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
|
||||
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
self.reset_swept(swept);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -345,10 +341,25 @@ impl GamepadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
|
||||
/// unplug clear the same things.
|
||||
fn reset_swept(&mut self, swept: u16) {
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
// Finish any unplug whose removal frame only armed the grace — the producer sends that
|
||||
// frame once, so without this the XUSB devnode would outlive the controller.
|
||||
let swept = self.slots.reap();
|
||||
self.reset_swept(swept);
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
|
||||
@@ -52,6 +52,8 @@ pub mod keymap_sdl;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod overlay;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod present_pace;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod touch;
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
|
||||
//! store, clock, and gate the run loop composes into the two intents.
|
||||
//!
|
||||
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
|
||||
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
|
||||
//! three clients agree on what the intents mean.
|
||||
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
|
||||
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
|
||||
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
|
||||
//! last submit on the mode's refresh period.
|
||||
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
|
||||
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
|
||||
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
|
||||
//! queue and never needs it.
|
||||
//!
|
||||
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
|
||||
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
|
||||
//! owns all clocks and Vulkan calls, which is what keeps this testable.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Stale-present force-open: an undisplayed present older than this is presumed lost
|
||||
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
|
||||
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
|
||||
const STALE_REOPEN_NS: u64 = 100_000_000;
|
||||
|
||||
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
|
||||
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
|
||||
/// when measured misses demand it).
|
||||
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
|
||||
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
|
||||
|
||||
/// The decoded-frame store between the wake channel and the present call.
|
||||
///
|
||||
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
|
||||
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
|
||||
/// an underflow after preroll re-arms the preroll (the previous frame persists on
|
||||
/// glass — a repeat by omission) while headroom rebuilds.
|
||||
pub(crate) struct FrameStore<T> {
|
||||
capacity: usize,
|
||||
frames: VecDeque<T>,
|
||||
prerolled: bool,
|
||||
/// Newest-wins displacements (normal operation under latency, not a fault signal).
|
||||
replaced: u32,
|
||||
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
|
||||
overflow_drops: u32,
|
||||
/// FIFO dry-after-preroll events — `qDry`.
|
||||
underflows: u32,
|
||||
}
|
||||
|
||||
impl<T> FrameStore<T> {
|
||||
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
|
||||
FrameStore {
|
||||
capacity,
|
||||
frames: VecDeque::with_capacity(capacity.max(1) + 1),
|
||||
prerolled: false,
|
||||
replaced: 0,
|
||||
overflow_drops: 0,
|
||||
underflows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_smoothing(&self) -> bool {
|
||||
self.capacity > 0
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn submit(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.pop_front().is_some() {
|
||||
self.replaced += 1;
|
||||
}
|
||||
self.frames.push_back(f);
|
||||
} else {
|
||||
self.frames.push_back(f);
|
||||
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
|
||||
// flowing. Also trims a transient capacity+1 a put_back left behind.
|
||||
while self.frames.len() > self.capacity {
|
||||
self.frames.pop_front();
|
||||
self.overflow_drops += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> Option<T> {
|
||||
if self.capacity == 0 {
|
||||
return self.frames.pop_front();
|
||||
}
|
||||
if !self.prerolled {
|
||||
// Preroll gate: without it a steady stream drains every frame on arrival
|
||||
// and jitter headroom never builds (the Apple store's lesson).
|
||||
if self.frames.len() < self.capacity {
|
||||
return None;
|
||||
}
|
||||
self.prerolled = true;
|
||||
}
|
||||
match self.frames.pop_front() {
|
||||
Some(f) => Some(f),
|
||||
None => {
|
||||
self.underflows += 1;
|
||||
self.prerolled = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame taken but not presented (gate closed, present failed before consuming
|
||||
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
|
||||
/// FIFO puts it back at the front (it is the oldest).
|
||||
pub(crate) fn put_back(&mut self, f: T) {
|
||||
if self.capacity == 0 {
|
||||
if self.frames.is_empty() {
|
||||
self.frames.push_back(f);
|
||||
}
|
||||
} else {
|
||||
self.frames.push_front(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
|
||||
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
|
||||
/// frames make buffering pointless anyway).
|
||||
///
|
||||
/// Gated with its only caller: the power-user build (`--no-default-features`, which
|
||||
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
|
||||
/// is dead code there.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
pub(crate) fn force_latency(&mut self) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
self.capacity = 0;
|
||||
self.prerolled = false;
|
||||
while self.frames.len() > 1 {
|
||||
self.frames.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
|
||||
let c = (self.replaced, self.overflow_drops, self.underflows);
|
||||
self.replaced = 0;
|
||||
self.overflow_drops = 0;
|
||||
self.underflows = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
|
||||
/// forward for slot targeting.
|
||||
///
|
||||
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
|
||||
/// rule. An earlier version of this clock capped the learned period at the display
|
||||
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
|
||||
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
|
||||
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
|
||||
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
|
||||
/// compositor running its own rate) a downward-only learner pins a grid that never
|
||||
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
|
||||
/// narrowing at once, widening only after eight consecutive agreeing observations and
|
||||
/// then to the narrowest of them.
|
||||
///
|
||||
/// What is fed to it is still the window's MIN spacing: within one window that resists
|
||||
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
|
||||
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
|
||||
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
|
||||
pub(crate) struct LatchClock {
|
||||
anchor_ns: u64,
|
||||
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
|
||||
/// every pass, so a "batch" is very often a single stamp — computing spacings only
|
||||
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
|
||||
/// silently ran on its seed forever.
|
||||
last_ns: u64,
|
||||
/// Narrowest spacing seen since the last handoff to the grid, and how many have
|
||||
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
|
||||
/// observations are the spacing of OUR presents, which is k×period whenever the
|
||||
/// stream runs below panel rate, and the min over a run is the best available
|
||||
/// estimate of the true grid step.
|
||||
pending_min_ns: u64,
|
||||
pending_count: u32,
|
||||
grid: punktfunk_core::phase::PanelGrid,
|
||||
fallback_period_ns: u64,
|
||||
}
|
||||
|
||||
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
|
||||
/// mode change is picked up in well under a second at any sane frame rate.
|
||||
const GRID_OBSERVE_EVERY: u32 = 16;
|
||||
|
||||
impl LatchClock {
|
||||
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
|
||||
LatchClock {
|
||||
anchor_ns: 0,
|
||||
last_ns: 0,
|
||||
pending_min_ns: 0,
|
||||
pending_count: 0,
|
||||
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
|
||||
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
|
||||
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
|
||||
/// the learner.
|
||||
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
|
||||
for &s in stamps {
|
||||
if self.last_ns != 0 && s > self.last_ns {
|
||||
let d = s - self.last_ns;
|
||||
// < 1 ms apart = a queued pair, not a grid step.
|
||||
if d > 1_000_000 {
|
||||
self.pending_min_ns = if self.pending_min_ns == 0 {
|
||||
d
|
||||
} else {
|
||||
self.pending_min_ns.min(d)
|
||||
};
|
||||
self.pending_count += 1;
|
||||
if self.pending_count >= GRID_OBSERVE_EVERY {
|
||||
self.grid.observe(self.pending_min_ns as i64);
|
||||
self.pending_min_ns = 0;
|
||||
self.pending_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_ns = s;
|
||||
}
|
||||
if let Some(&last) = stamps.last() {
|
||||
self.anchor_ns = last;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn period_ns(&self) -> u64 {
|
||||
let learned = self.grid.period_ns();
|
||||
if learned > 0 {
|
||||
learned as u64
|
||||
} else {
|
||||
self.fallback_period_ns
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn anchor_ns(&self) -> u64 {
|
||||
self.anchor_ns
|
||||
}
|
||||
|
||||
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
|
||||
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
|
||||
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
|
||||
let p = self.period_ns();
|
||||
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
|
||||
return after_ns.saturating_add(p);
|
||||
}
|
||||
let k = (after_ns - self.anchor_ns) / p + 1;
|
||||
self.anchor_ns + k * p
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the panel is refreshing on a fixed grid or following our cadence.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum Cadence {
|
||||
/// Not enough evidence yet — say nothing rather than guess.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
|
||||
Fixed,
|
||||
/// On-glass instants track our present spacing instead: variable refresh is live.
|
||||
Variable,
|
||||
}
|
||||
|
||||
impl Cadence {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Cadence::Unknown => "",
|
||||
Cadence::Fixed => "no",
|
||||
Cadence::Variable => "yes",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is variable refresh actually live? **Measured, never queried** — no portable query
|
||||
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
|
||||
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
|
||||
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
|
||||
///
|
||||
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
|
||||
/// lands on the vblank grid, so the spacing between consecutive presents is always
|
||||
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
|
||||
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
|
||||
/// follows our own cadence and sits wherever it likes relative to the grid.
|
||||
///
|
||||
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
|
||||
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
|
||||
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
|
||||
/// harmless — at that rate VRR has nothing to do.
|
||||
pub(crate) struct CadenceProbe {
|
||||
/// Off-grid distances as a fraction of the period, in thousandths.
|
||||
off_grid_milli: Vec<u32>,
|
||||
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
|
||||
/// live drain hands over one sample at a time.
|
||||
last_ns: u64,
|
||||
/// The last round's raw reading and how many rounds have agreed — a verdict is only
|
||||
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
|
||||
candidate: Cadence,
|
||||
agree_rounds: u8,
|
||||
verdict: Cadence,
|
||||
}
|
||||
|
||||
/// Enough deltas to distinguish jitter from a real off-grid cadence.
|
||||
const CADENCE_MIN_SAMPLES: usize = 24;
|
||||
/// Consecutive agreeing rounds before a verdict is published.
|
||||
///
|
||||
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
|
||||
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
|
||||
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
|
||||
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
|
||||
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
|
||||
/// therefore only ever say "presents are not landing on the grid", so it demands
|
||||
/// agreement across rounds and refuses evidence from a distressed window (see
|
||||
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
|
||||
const CADENCE_STABLE_ROUNDS: u8 = 2;
|
||||
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
|
||||
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
|
||||
/// this is deliberately loose — the two regimes differ by far more than this in practice.
|
||||
const CADENCE_FIXED_MILLI: u32 = 150;
|
||||
|
||||
impl CadenceProbe {
|
||||
pub(crate) fn new() -> CadenceProbe {
|
||||
CadenceProbe {
|
||||
off_grid_milli: Vec::with_capacity(64),
|
||||
last_ns: 0,
|
||||
candidate: Cadence::Unknown,
|
||||
agree_rounds: 0,
|
||||
verdict: Cadence::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps against the learned panel period. Spacings are measured
|
||||
/// against the previous stamp whatever the batching.
|
||||
///
|
||||
/// `healthy` is the caller's statement that this window's presents were flowing
|
||||
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
|
||||
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
|
||||
/// continuity is still advanced, it simply does not count as a sample.
|
||||
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
|
||||
if period_ns == 0 || !healthy {
|
||||
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
|
||||
return;
|
||||
}
|
||||
for &s in stamps {
|
||||
let prev = std::mem::replace(&mut self.last_ns, s);
|
||||
if prev == 0 || s <= prev {
|
||||
continue;
|
||||
}
|
||||
let delta = s - prev;
|
||||
let rem = delta % period_ns;
|
||||
// Distance to the NEAREST multiple, so a delta just under k×period reads as
|
||||
// close to the grid rather than a whole period away from k-1.
|
||||
let off = rem.min(period_ns - rem);
|
||||
self.off_grid_milli
|
||||
.push((off.saturating_mul(1000) / period_ns) as u32);
|
||||
// A round closes on the SAMPLE count, inside the loop — not once per call.
|
||||
// Evaluating per call would make the verdict depend on how the caller happens
|
||||
// to batch its stamps (one big batch = one round, forever short of the
|
||||
// agreement requirement), and the live drain and the tests batch differently.
|
||||
self.close_round_if_ready();
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a verdict once a round's worth of spacings agree with the previous round.
|
||||
fn close_round_if_ready(&mut self) {
|
||||
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
|
||||
self.off_grid_milli.sort_unstable();
|
||||
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
|
||||
let round = if median <= CADENCE_FIXED_MILLI {
|
||||
Cadence::Fixed
|
||||
} else {
|
||||
Cadence::Variable
|
||||
};
|
||||
if round == self.candidate {
|
||||
self.agree_rounds = self.agree_rounds.saturating_add(1);
|
||||
} else {
|
||||
self.candidate = round;
|
||||
self.agree_rounds = 1;
|
||||
}
|
||||
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
|
||||
self.verdict = round;
|
||||
}
|
||||
self.off_grid_milli.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verdict(&self) -> Cadence {
|
||||
self.verdict
|
||||
}
|
||||
|
||||
/// A mode switch / display change invalidates the evidence.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.off_grid_milli.clear();
|
||||
self.last_ns = 0;
|
||||
self.candidate = Cadence::Unknown;
|
||||
self.agree_rounds = 0;
|
||||
self.verdict = Cadence::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
|
||||
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
|
||||
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
|
||||
/// then exactly the shipped arrival pacing).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PresentGate {
|
||||
/// Submit stamp of the newest tracked present; 0 = none yet.
|
||||
last_present_ns: u64,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
impl PresentGate {
|
||||
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
|
||||
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
|
||||
/// stream survives, counted as `forced`.
|
||||
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
|
||||
if outstanding == 0 {
|
||||
return true;
|
||||
}
|
||||
if self.last_present_ns != 0
|
||||
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
|
||||
{
|
||||
self.forced += 1;
|
||||
return true;
|
||||
}
|
||||
self.gated += 1;
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn note_present(&mut self, now_ns: u64) {
|
||||
self.last_present_ns = now_ns;
|
||||
}
|
||||
|
||||
/// Drain the window's counters: `(gated, forced)`.
|
||||
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
|
||||
let c = (self.gated, self.forced);
|
||||
self.gated = 0;
|
||||
self.forced = 0;
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
|
||||
#[test]
|
||||
fn newest_wins_replaces_and_putback_never_clobbers() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(0);
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives");
|
||||
assert_eq!(s.take(), None);
|
||||
// A taken-but-unpresented frame returns — unless a fresher one arrived.
|
||||
s.submit(4);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(4));
|
||||
let f = s.take();
|
||||
assert_eq!(f, None);
|
||||
s.submit(5);
|
||||
let f = s.take().unwrap();
|
||||
s.submit(6);
|
||||
s.put_back(f); // 6 arrived while 5 was out — 6 wins
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(
|
||||
s.take_counters(),
|
||||
(2, 0, 0),
|
||||
"two displacements, no fifo counters"
|
||||
);
|
||||
}
|
||||
|
||||
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
|
||||
#[test]
|
||||
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
assert!(s.is_smoothing());
|
||||
s.submit(1);
|
||||
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
|
||||
s.submit(2);
|
||||
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
|
||||
assert_eq!(
|
||||
s.take(),
|
||||
Some(2),
|
||||
"once prerolled the buffer drains normally"
|
||||
);
|
||||
// Dry after preroll = one underflow, preroll re-arms.
|
||||
assert_eq!(s.take(), None);
|
||||
s.submit(3);
|
||||
assert_eq!(s.take(), None, "re-armed preroll holds again");
|
||||
s.submit(4);
|
||||
assert_eq!(s.take(), Some(3));
|
||||
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
|
||||
s.submit(5);
|
||||
s.submit(6);
|
||||
s.submit(7);
|
||||
assert_eq!(s.take(), Some(6));
|
||||
assert_eq!(s.take(), Some(7));
|
||||
let (replaced, drops, dry) = s.take_counters();
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
|
||||
assert_eq!(dry, 1);
|
||||
}
|
||||
|
||||
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
|
||||
/// capacity+1 is trimmed by the next submit.
|
||||
#[test]
|
||||
fn fifo_putback_restores_order() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(2);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
let f = s.take().unwrap();
|
||||
s.put_back(f);
|
||||
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
|
||||
}
|
||||
|
||||
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn force_latency_collapses_to_one_slot() {
|
||||
let mut s: FrameStore<u32> = FrameStore::new(3);
|
||||
s.submit(1);
|
||||
s.submit(2);
|
||||
s.submit(3);
|
||||
s.force_latency();
|
||||
assert!(!s.is_smoothing());
|
||||
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
|
||||
s.submit(4);
|
||||
s.submit(5);
|
||||
assert_eq!(s.take(), Some(5));
|
||||
}
|
||||
|
||||
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
|
||||
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
|
||||
/// double-present) never become the period.
|
||||
#[test]
|
||||
fn latch_clock_learns_and_extrapolates() {
|
||||
const P: u64 = 16_666_666; // 60 Hz
|
||||
let mut c = LatchClock::new(60);
|
||||
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
|
||||
// No anchor: a usable deadline one period out.
|
||||
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
|
||||
|
||||
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
|
||||
let next = c.next_slot_after(c.anchor_ns());
|
||||
assert_eq!(next, 1_000_000_000 + 3 * P);
|
||||
// Mid-slot query lands on the same boundary; a later one steps whole periods.
|
||||
assert_eq!(c.next_slot_after(next - 1), next);
|
||||
assert_eq!(c.next_slot_after(next), next + P);
|
||||
|
||||
// A queued pair (< 1 ms apart) must not poison the period.
|
||||
c.note_batch(&[2_000_000_000, 2_000_000_500]);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
|
||||
|
||||
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
|
||||
// such window must NOT move the grid — the shared learner needs a streak before
|
||||
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
|
||||
// panel while still allowing a genuinely slower display to be discovered.
|
||||
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
|
||||
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
|
||||
|
||||
// A single stamp re-anchors without touching the period.
|
||||
c.note_batch(&[5_000_000_000]);
|
||||
assert_eq!(c.anchor_ns(), 5_000_000_000);
|
||||
assert_eq!(c.period_ns(), P);
|
||||
|
||||
// A faster panel learns its own finer grid.
|
||||
let mut fast = LatchClock::new(120);
|
||||
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
|
||||
assert_eq!(fast.period_ns(), 8_333_333);
|
||||
}
|
||||
|
||||
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
|
||||
/// time. Measuring spacings only within a batch meant the learner observed nothing on
|
||||
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
|
||||
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
|
||||
/// wrong the moment the mode lies).
|
||||
#[test]
|
||||
fn latch_clock_learns_from_one_sample_at_a_time() {
|
||||
const REAL: u64 = 16_666_666;
|
||||
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]); // ONE stamp per call — the live shape
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"single-stamp batches must still feed the grid learner"
|
||||
);
|
||||
assert_eq!(c.anchor_ns(), t);
|
||||
}
|
||||
|
||||
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
|
||||
/// compositor running its own rate leaves the seed too fast. The old downward-only
|
||||
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
|
||||
/// shared learner climbs back out once the evidence is consistent.
|
||||
#[test]
|
||||
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
|
||||
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
|
||||
let mut c = LatchClock::new(120); // …but the mode claimed 120
|
||||
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
|
||||
|
||||
// Consistent 60 Hz evidence. The grid is fed the MIN of every
|
||||
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
|
||||
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
|
||||
// deliberate cost of not letting one slow patch redefine the panel.
|
||||
let mut t = 1_000_000_000u64;
|
||||
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
|
||||
t += REAL;
|
||||
c.note_batch(&[t]);
|
||||
}
|
||||
assert_eq!(
|
||||
c.period_ns(),
|
||||
REAL,
|
||||
"a sustained slower grid is adopted instead of aimed past forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
|
||||
/// landing wherever our own cadence puts them read Variable — including the case that
|
||||
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
|
||||
/// to a larger whole multiple.
|
||||
#[test]
|
||||
fn cadence_probe_separates_grid_locked_from_variable() {
|
||||
const P: u64 = 8_333_333; // 120 Hz
|
||||
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
|
||||
// only once consecutive rounds agree (on glass a single round FLAPPED).
|
||||
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
// Fixed panel, stream at panel rate: every delta is exactly one period.
|
||||
let mut probe = CadenceProbe::new();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed);
|
||||
|
||||
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Fixed,
|
||||
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
|
||||
);
|
||||
|
||||
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
|
||||
// must not read as variable.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
|
||||
let stamps: Vec<u64> = (0..ROUNDS as usize)
|
||||
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
|
||||
|
||||
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
|
||||
// 8.33 ms, so every present sits off the grid.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Variable);
|
||||
|
||||
// A display change throws the evidence away rather than carrying a stale verdict.
|
||||
probe.reset();
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// Below the sample floor nothing is claimed.
|
||||
let mut probe = CadenceProbe::new();
|
||||
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
|
||||
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
|
||||
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
|
||||
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
|
||||
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
|
||||
let mut probe = CadenceProbe::new();
|
||||
for i in 0..ROUNDS {
|
||||
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
|
||||
}
|
||||
assert_eq!(
|
||||
probe.verdict(),
|
||||
Cadence::Variable,
|
||||
"one-sample batches must still yield spacings"
|
||||
);
|
||||
|
||||
// A period we never learned can't discriminate anything.
|
||||
let mut probe = CadenceProbe::new();
|
||||
let stamps: Vec<u64> = (0..ROUNDS)
|
||||
.map(|i| 1_000_000_000 + i * 10_000_000)
|
||||
.collect();
|
||||
probe.note(&stamps, 0, true);
|
||||
assert_eq!(probe.verdict(), Cadence::Unknown);
|
||||
}
|
||||
|
||||
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
|
||||
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
|
||||
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
|
||||
/// call boundaries silently made the two disagree.
|
||||
#[test]
|
||||
fn cadence_verdict_is_independent_of_batching() {
|
||||
const P: u64 = 8_333_333;
|
||||
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
|
||||
|
||||
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
|
||||
let mut bulk = CadenceProbe::new();
|
||||
bulk.note(&stamps, P, true);
|
||||
|
||||
let mut drip = CadenceProbe::new();
|
||||
for s in &stamps {
|
||||
drip.note(&[*s], P, true);
|
||||
}
|
||||
|
||||
assert_eq!(bulk.verdict(), Cadence::Fixed);
|
||||
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
|
||||
}
|
||||
|
||||
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
|
||||
#[test]
|
||||
fn gate_budgets_one_undisplayed_present() {
|
||||
let mut g = PresentGate::default();
|
||||
let t0 = 1_000_000_000u64;
|
||||
assert!(g.open(0, t0));
|
||||
g.note_present(t0);
|
||||
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
|
||||
assert!(
|
||||
g.open(1, t0 + STALE_REOPEN_NS + 1),
|
||||
"stale in-flight present force-opens"
|
||||
);
|
||||
let (gated, forced) = g.take_counters();
|
||||
assert_eq!((gated, forced), (1, 1));
|
||||
assert_eq!(g.take_counters(), (0, 0), "counters drain");
|
||||
}
|
||||
}
|
||||
+492
-45
@@ -18,12 +18,15 @@
|
||||
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -63,6 +66,20 @@ pub struct SessionOpts {
|
||||
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
|
||||
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
|
||||
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
|
||||
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
|
||||
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
|
||||
/// the whole engine back to the legacy drain for field A/B without a rebuild.
|
||||
pub present_priority: PresentPriority,
|
||||
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
|
||||
/// present mode for the lowest possible latch — best-effort, and the mode that
|
||||
/// actually took is named in the stats line.
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
|
||||
/// default on) — prefers the present mode that drives VRR panels directly when the
|
||||
/// session starts fullscreen.
|
||||
pub allow_vrr: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -213,8 +230,47 @@ struct StreamState {
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
win_disp_us: Vec<u64>,
|
||||
/// The display stage's two halves (present-timing sessions only): decoded→submit and
|
||||
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
|
||||
win_pace_us: Vec<u64>,
|
||||
win_latch_us: Vec<u64>,
|
||||
win_start: Instant,
|
||||
presented: PresentedWindow,
|
||||
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
|
||||
/// store between the wake channel and the present call — a newest-wins slot under
|
||||
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
|
||||
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
|
||||
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
|
||||
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
|
||||
store: FrameStore<DecodedFrame>,
|
||||
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
|
||||
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
|
||||
clock: LatchClock,
|
||||
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
|
||||
/// or without present timing.
|
||||
gate: PresentGate,
|
||||
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
|
||||
/// portable query exists) — see [`CadenceProbe`].
|
||||
cadence: CadenceProbe,
|
||||
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
|
||||
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
|
||||
/// period (see the probe's call site).
|
||||
mode_period_ns: u64,
|
||||
/// The latch slot the last smoothness present served (one present per slot); 0 =
|
||||
/// none yet.
|
||||
last_target_ns: u64,
|
||||
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
|
||||
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
|
||||
margin_ns: u64,
|
||||
/// This window's latch misses (a present that reached glass > 1.5 latch periods
|
||||
/// after submit) — the adaptive margin's error signal.
|
||||
win_misses: u32,
|
||||
/// This window's peak undisplayed-presents-in-flight (present timing only).
|
||||
win_out_max: usize,
|
||||
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
|
||||
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: bool,
|
||||
// Hardware-path health: a failure streak (or a device with no import support at
|
||||
// all) demotes the decoder to software via the shared flag — once per session.
|
||||
dmabuf_demoted: bool,
|
||||
@@ -279,6 +335,8 @@ impl StreamState {
|
||||
params: SessionParams,
|
||||
force_software: Arc<AtomicBool>,
|
||||
wake: sdl3::event::EventSender,
|
||||
priority: PresentPriority,
|
||||
native_refresh_hz: u32,
|
||||
) -> StreamState {
|
||||
let profile = params.profile.clone();
|
||||
// The presenter's half of phase-locked capture: it writes the latch grid the
|
||||
@@ -316,8 +374,21 @@ impl StreamState {
|
||||
hdr_untonemapped: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_pace_us: Vec::with_capacity(256),
|
||||
win_latch_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
presented: PresentedWindow::default(),
|
||||
store: FrameStore::new(usize::from(priority.fifo_capacity())),
|
||||
clock: LatchClock::new(native_refresh_hz),
|
||||
gate: PresentGate::default(),
|
||||
cadence: CadenceProbe::new(),
|
||||
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
|
||||
last_target_ns: 0,
|
||||
margin_ns: 0,
|
||||
win_misses: 0,
|
||||
win_out_max: 0,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_latency_forced: false,
|
||||
dmabuf_demoted: false,
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
pyro_present_warned: false,
|
||||
@@ -356,6 +427,25 @@ impl StreamState {
|
||||
}
|
||||
self.handle.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
|
||||
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
|
||||
/// tick (frames, input, and present completions all wake the loop early anyway).
|
||||
fn wake_timeout(&self) -> Duration {
|
||||
const TICK: Duration = Duration::from_millis(15);
|
||||
if !self.store.is_smoothing() || self.store.is_empty() {
|
||||
return TICK;
|
||||
}
|
||||
let now = session::now_ns();
|
||||
let mut target = self
|
||||
.clock
|
||||
.next_slot_after(now.saturating_add(self.margin_ns));
|
||||
if target == self.last_target_ns {
|
||||
// This slot is already served — the next boundary is the deadline.
|
||||
target += self.clock.period_ns();
|
||||
}
|
||||
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
|
||||
@@ -438,9 +528,43 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let instance_exts = window
|
||||
.vulkan_instance_extensions()
|
||||
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
|
||||
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
|
||||
let mut presenter = Presenter::new(
|
||||
&window,
|
||||
&instance_exts,
|
||||
crate::vk::PresentPref {
|
||||
vsync: opts.vsync,
|
||||
allow_vrr: opts.allow_vrr,
|
||||
fullscreen: opts.fullscreen,
|
||||
// `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are
|
||||
// both resolved inside `Presenter::new` — the swapchain owns those, so every
|
||||
// caller gets the same answer. `..Default` keeps this site from breaking each
|
||||
// time the struct learns another one.
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.context("vulkan presenter")?;
|
||||
// A valid black frame immediately — the window is honest while the connect runs.
|
||||
presenter.present(&window, FrameInput::Redraw, None)?;
|
||||
|
||||
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
|
||||
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
|
||||
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
|
||||
let present_priority = if arrival_override {
|
||||
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
|
||||
PresentPriority::Latency
|
||||
} else {
|
||||
opts.present_priority
|
||||
};
|
||||
let pacing_active = !arrival_override;
|
||||
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
|
||||
// Present completions wake the loop exactly like decoded frames: a glass-gate
|
||||
// reopen or a smoothness slot must not wait out the event timeout.
|
||||
{
|
||||
let sender = events.event_sender();
|
||||
presenter.set_present_wake(Box::new(move || {
|
||||
let _ = sender.push_custom_event(FrameWake);
|
||||
}));
|
||||
}
|
||||
// Browse mode is "ready" the moment the library window presents — there may never be
|
||||
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
|
||||
// a shell only yields to a window that actually shows the stream.)
|
||||
@@ -517,6 +641,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
))
|
||||
}
|
||||
ModeCtl::Browse(_) => None,
|
||||
@@ -544,8 +670,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
|
||||
// when there is work — a short-timeout poll here burned a full core (measured;
|
||||
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway.
|
||||
let timeout = Duration::from_millis(15);
|
||||
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
|
||||
// stream tightens the bound to its next latch-slot deadline.
|
||||
let timeout = stream
|
||||
.as_ref()
|
||||
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
|
||||
let first = event_pump.wait_event_timeout(timeout);
|
||||
let mut queued: Vec<Event> = Vec::new();
|
||||
if let Some(e) = first {
|
||||
@@ -608,6 +737,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dragged to another monitor (or the mode changed under us): the
|
||||
// latch grid and the VRR verdict both belong to the OLD panel. The
|
||||
// refresh rate used to be read once at startup and never revisited,
|
||||
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
|
||||
WindowEvent::DisplayChanged(..) => {
|
||||
let hz = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| m.refresh_rate.round().max(0.0) as u32)
|
||||
.unwrap_or(0);
|
||||
if let Some(st) = stream.as_mut() {
|
||||
if hz > 0 {
|
||||
st.clock = LatchClock::new(hz);
|
||||
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
|
||||
}
|
||||
st.cadence.reset();
|
||||
st.last_target_ns = 0;
|
||||
tracing::info!(
|
||||
refresh_hz = hz,
|
||||
"display changed — relearning the latch grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowEvent::Exposed => {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
@@ -1032,6 +1184,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
present_priority,
|
||||
native.refresh_hz,
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
@@ -1279,11 +1433,148 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
// Present-wait completions drive the latch clock, the glass gate, and the
|
||||
// host-facing grid — drained every pass (a 1 Hz batch would starve all
|
||||
// three; the waiter's SDL wake pairs with this so completions never wait
|
||||
// out the event timeout).
|
||||
if presenter.present_timing_active() {
|
||||
let samples = presenter.take_presented_samples();
|
||||
if !samples.is_empty() {
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let period = st.clock.period_ns();
|
||||
let mut stamps = Vec::with_capacity(samples.len());
|
||||
for s in &samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
|
||||
- s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
// The display split (WP4): our pipeline vs the vsync latch. Only
|
||||
// meaningful with true glass stamps, which is exactly when this
|
||||
// branch runs.
|
||||
st.win_pace_us
|
||||
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
st.win_latch_us
|
||||
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
|
||||
// Latch miss (the adaptive margin's error signal): glass later
|
||||
// than one panel period past submit, PLUS the lead we already
|
||||
// applied — i.e. the slot we aimed at was missed. Measuring the
|
||||
// real latch rather than the store's own evictions is the
|
||||
// Android 0.23.0 correction: policy drops happen whenever the
|
||||
// stream out-runs the panel and say nothing about the latch, and
|
||||
// widening on them walked the margin to its ceiling on healthy
|
||||
// devices, re-imposing the very display latency it had removed.
|
||||
if st.store.is_smoothing()
|
||||
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
|
||||
{
|
||||
st.win_misses += 1;
|
||||
}
|
||||
stamps.push(s.displayed_ns);
|
||||
}
|
||||
st.clock.note_batch(&stamps);
|
||||
// Same stamps answer "is VRR live" — the panel either quantizes them
|
||||
// to its grid or follows our cadence. Evidence only counts from a
|
||||
// window whose presents were flowing normally: a distressed pipeline
|
||||
// (stale force-opens) smears spacings for reasons that have nothing
|
||||
// to do with the panel, and on glass that flapped the verdict.
|
||||
//
|
||||
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
|
||||
// The learned grid comes from our own present spacings, and a stream
|
||||
// running below panel rate only ever produces multiples ≥ its frame
|
||||
// interval — so the learner adopts our cadence as "the grid" and every
|
||||
// delta then looks on-grid by construction. Measured on .21
|
||||
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
|
||||
// and the probe reported VRR on a display with VRR provably disabled.
|
||||
// The vblank grid is the mode's refresh; that is what presents
|
||||
// quantize to when VRR is off.
|
||||
//
|
||||
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
|
||||
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
|
||||
// IMMEDIATE deliberately break that, so their stamps are never
|
||||
// grid-quantized and the probe would call every mailbox session VRR.
|
||||
// Measured on .21: same panel, same second — fifo read `no`
|
||||
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
|
||||
// FIFO the honest answer is "cannot tell", i.e. Unknown.
|
||||
let healthy = st.presented.forced == 0;
|
||||
if presenter.vblank_locked() {
|
||||
st.cadence.note(&stamps, st.mode_period_ns, healthy);
|
||||
}
|
||||
// Phase-locked capture, the presenter's half: publish the grid the
|
||||
// local clock just learned — a recent TRUE on-glass instant plus
|
||||
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
|
||||
// feeds both, so the report and the scheduler cannot disagree.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
grid.period_ns
|
||||
.store(st.clock.period_ns(), Ordering::Relaxed);
|
||||
grid.anchor_ns
|
||||
.store(st.clock.anchor_ns(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
|
||||
// Intake into the intent store: a newest-wins slot under latency (the
|
||||
// shipped drain, now with displacement counters), the smoothing FIFO under
|
||||
// smoothness. PyroWave collapses smoothness to latency for the stream: its
|
||||
// plane-ring retirement accounting assumes the newest-wins hand-off
|
||||
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
|
||||
// buffering moot anyway.
|
||||
while let Ok(f) = st.frames.try_recv() {
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||||
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
|
||||
st.store.force_latency();
|
||||
if !st.pyro_latency_forced {
|
||||
st.pyro_latency_forced = true;
|
||||
tracing::info!(
|
||||
"PyroWave stream — smoothness buffering does not apply \
|
||||
(latency pacing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
st.store.submit(f);
|
||||
}
|
||||
|
||||
// One frame out, by intent: latency takes the newest whenever the glass
|
||||
// gate allows; smoothness serves at most one frame per latch slot (the
|
||||
// preroll/underflow behavior lives in the store).
|
||||
let now_ns = session::now_ns();
|
||||
let mut slot_target = 0u64;
|
||||
let mut to_present = if st.store.is_smoothing() {
|
||||
let target = st
|
||||
.clock
|
||||
.next_slot_after(now_ns.saturating_add(st.margin_ns));
|
||||
if target != st.last_target_ns {
|
||||
slot_target = target;
|
||||
st.store.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
st.store.take()
|
||||
};
|
||||
// The FIFO glass budget: one undisplayed present in flight, so the
|
||||
// swapchain's own FIFO can never become a standing queue (a measured
|
||||
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
|
||||
// only present timing can count, so everywhere else this stays inert and
|
||||
// behavior is the shipped arrival pacing.
|
||||
if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() {
|
||||
if let Some(f) = to_present.take() {
|
||||
if st.gate.open(presenter.presents_outstanding(), now_ns) {
|
||||
to_present = Some(f);
|
||||
} else {
|
||||
// Parked: a newest-wins store replaces it if a fresher frame
|
||||
// lands; the waiter's wake (or the 100 ms stale force-open)
|
||||
// retries.
|
||||
st.store.put_back(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = to_present {
|
||||
// Resize END: a frame at the steered target size means the sharp new-mode
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
@@ -1472,6 +1763,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
};
|
||||
if did_present {
|
||||
presented_video = true;
|
||||
// Smoothness: this latch slot is served — one present per slot.
|
||||
// (Set only on success: a gated or failed present leaves the slot
|
||||
// open for the retry.)
|
||||
if slot_target != 0 {
|
||||
st.last_target_ns = slot_target;
|
||||
}
|
||||
if opts.json_status && !st.ready_announced {
|
||||
st.ready_announced = true;
|
||||
println!("{{\"ready\":true}}");
|
||||
@@ -1481,6 +1778,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// e2e/display samples arrive via `take_presented_samples` with a
|
||||
// TRUE on-glass stamp instead of the submit-time one below.
|
||||
presenter.note_presented(pts_ns, decoded_ns);
|
||||
st.gate.note_present(now_ns);
|
||||
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
|
||||
} else {
|
||||
let displayed_ns = session::now_ns();
|
||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||
@@ -1495,59 +1794,81 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
|
||||
// No glass stamps on this stack: the submit instant anchors an
|
||||
// approximate grid on the mode's refresh period, so smoothness
|
||||
// still drains one frame per (approximate) slot.
|
||||
st.clock.note_batch(&[displayed_ns]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fold the presenter window into the shared stats line once per second.
|
||||
// (The on-glass samples themselves are drained every pass above — they
|
||||
// drive the latch clock and glass gate, not just this fold.)
|
||||
if st.win_start.elapsed() >= Duration::from_secs(1) {
|
||||
// On-glass samples the present-wait waiter completed this window (empty
|
||||
// when timing is inactive — the legacy submit-time pushes fill in then).
|
||||
let clock_offset_ns = st
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
let samples = presenter.take_presented_samples();
|
||||
// Phase-locked capture, the presenter's half: publish this window's latch
|
||||
// grid — a recent TRUE on-glass instant plus the panel period — for the
|
||||
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
|
||||
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
|
||||
// by the display mode's refresh — under arrival-paced MAILBOX a stream
|
||||
// running below the panel rate spaces its presents at k×period, and the
|
||||
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
if let Some(last) = samples.last() {
|
||||
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
|
||||
let min_delta = samples
|
||||
.windows(2)
|
||||
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
|
||||
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
|
||||
.min()
|
||||
.unwrap_or(refresh_period);
|
||||
grid.period_ns
|
||||
.store(min_delta.min(refresh_period), Ordering::Relaxed);
|
||||
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
for s in samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
st.win_e2e_us.push(e2e / 1000);
|
||||
}
|
||||
st.win_disp_us
|
||||
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
|
||||
}
|
||||
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
|
||||
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
|
||||
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
|
||||
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
|
||||
// Drained ONCE per window and shared by the HUD and the log line below —
|
||||
// a second `take_counters` would read zeros.
|
||||
let (replaced, q_drop, q_dry) = st.store.take_counters();
|
||||
let (gated, forced) = st.gate.take_counters();
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
display_ms: disp_p50 as f32 / 1000.0,
|
||||
pace_ms: pace_p50 as f32 / 1000.0,
|
||||
latch_ms: latch_p50 as f32 / 1000.0,
|
||||
mode: presenter.present_mode_name(),
|
||||
vrr: st.cadence.verdict(),
|
||||
smoothing: st.store.is_smoothing(),
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
st.win_pace_us.clear();
|
||||
st.win_latch_us.clear();
|
||||
st.win_start = Instant::now();
|
||||
// Adaptive slot margin (the Android presenter's measured recipe):
|
||||
// start at 0 — a fixed lead is pure display tax — and widen one step
|
||||
// per window whose measured latch misses demand it. One-way per
|
||||
// stream; the next stream restarts at 0.
|
||||
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
|
||||
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
|
||||
tracing::info!(
|
||||
margin_us = st.margin_ns / 1000,
|
||||
misses = st.win_misses,
|
||||
"smoothness slot margin widened (measured latch misses)"
|
||||
);
|
||||
}
|
||||
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
|
||||
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
|
||||
// the field-triage instrument for the intent engine.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
vrr = st.presented.vrr.label(),
|
||||
replaced,
|
||||
q_drop,
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
misses = st.win_misses,
|
||||
out_max = st.win_out_max,
|
||||
pace_ms = st.presented.pace_ms,
|
||||
latch_ms = st.presented.latch_ms,
|
||||
period_us = st.clock.period_ns() / 1000,
|
||||
margin_us = st.margin_ns / 1000,
|
||||
"presenter window"
|
||||
);
|
||||
}
|
||||
st.win_misses = 0;
|
||||
st.win_out_max = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2007,6 +2328,32 @@ struct PresentedWindow {
|
||||
e2e_p50_ms: f32,
|
||||
e2e_p95_ms: f32,
|
||||
display_ms: f32,
|
||||
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
|
||||
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
|
||||
/// (the presentation engine's queue + the vblank wait). Both `0` without
|
||||
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
|
||||
/// unsplit figure rather than inventing a zero latch.
|
||||
///
|
||||
/// This split is what makes a high `display` self-diagnosing: latch dominating means
|
||||
/// the vsync/queue floor (or a standing queue), pace dominating means us.
|
||||
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
|
||||
/// number — Apple subtracts its measured OS present floor, and the latch IS our
|
||||
/// floor, so `pace` is what remains on both sides of that comparison.
|
||||
pace_ms: f32,
|
||||
latch_ms: f32,
|
||||
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
|
||||
/// chosen from what the surface offers, so "why is my latch a refresh long" is
|
||||
/// usually answered by a MAILBOX request having landed on FIFO.
|
||||
mode: &'static str,
|
||||
/// Whether variable refresh is measurably live (never claimed without evidence).
|
||||
vrr: Cadence,
|
||||
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
|
||||
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
|
||||
smoothing: bool,
|
||||
q_drop: u32,
|
||||
q_dry: u32,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
@@ -2112,6 +2459,15 @@ fn stats_text(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
// The display split (WP4). Only with true on-glass stamps — without them the
|
||||
// two halves are not separable and the unsplit figure stands alone rather than
|
||||
// implying a zero latch.
|
||||
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
|
||||
text.push_str(&format!(
|
||||
" (pace {:.1} + latch {:.1})",
|
||||
p.pace_ms, p.latch_ms
|
||||
));
|
||||
}
|
||||
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
|
||||
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
|
||||
if s.staged {
|
||||
@@ -2120,6 +2476,32 @@ fn stats_text(
|
||||
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
|
||||
));
|
||||
}
|
||||
// The presenter line: the swapchain mode that is actually live, the chosen
|
||||
// intent, and the engine's own counters. Present-mode alone answers most
|
||||
// "why is my latch a whole refresh" questions; the counters only render when
|
||||
// they are non-zero, so a healthy latency session shows just the mode.
|
||||
if !p.mode.is_empty() {
|
||||
text.push_str(&format!("\npresent: {}", p.mode));
|
||||
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
|
||||
if p.vrr != Cadence::Unknown {
|
||||
text.push_str(&format!(" · vrr {}", p.vrr.label()));
|
||||
}
|
||||
if p.smoothing {
|
||||
text.push_str(" · smoothing");
|
||||
}
|
||||
if p.q_drop > 0 {
|
||||
text.push_str(&format!(" · qdrop {}", p.q_drop));
|
||||
}
|
||||
if p.q_dry > 0 {
|
||||
text.push_str(&format!(" · qdry {}", p.q_dry));
|
||||
}
|
||||
if p.gated > 0 {
|
||||
text.push_str(&format!(" · gated {}", p.gated));
|
||||
}
|
||||
if p.forced > 0 {
|
||||
text.push_str(&format!(" · forced {}", p.forced));
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lost > 0 {
|
||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||
@@ -2393,6 +2775,7 @@ mod tests {
|
||||
e2e_p50_ms: 6.4,
|
||||
e2e_p95_ms: 9.1,
|
||||
display_ms: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2430,6 +2813,70 @@ mod tests {
|
||||
!normal.contains("queue"),
|
||||
"host-stage split is Detailed-only"
|
||||
);
|
||||
assert!(
|
||||
!detailed.contains("pace 1.1"),
|
||||
"no glass stamps in this sample — the display stage stays unsplit"
|
||||
);
|
||||
}
|
||||
|
||||
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
|
||||
/// live present mode is named, and the engine counters render only when non-zero —
|
||||
/// so a healthy latency session shows the mode and nothing else. Without glass
|
||||
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
|
||||
#[test]
|
||||
fn detailed_splits_display_into_pace_and_latch() {
|
||||
let (s, mut p) = sample();
|
||||
p.display_ms = 12.4;
|
||||
p.pace_ms = 1.1;
|
||||
p.latch_ms = 11.3;
|
||||
p.mode = "fifo";
|
||||
let split = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
|
||||
assert!(split.contains("\npresent: fifo"));
|
||||
assert!(
|
||||
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
|
||||
"quiet counters stay off the HUD: {split}"
|
||||
);
|
||||
|
||||
// The smoothing FIFO and the glass gate surface once they actually do something.
|
||||
p.smoothing = true;
|
||||
p.q_drop = 2;
|
||||
p.q_dry = 1;
|
||||
p.gated = 7;
|
||||
p.forced = 1;
|
||||
let busy = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
|
||||
|
||||
// A tier below Detailed never carries any of it.
|
||||
let normal = stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(!normal.contains("present:") && !normal.contains("pace"));
|
||||
}
|
||||
|
||||
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
|
||||
|
||||
@@ -33,7 +33,7 @@ mod reconfig;
|
||||
mod resources;
|
||||
mod setup;
|
||||
|
||||
pub use setup::list_adapters;
|
||||
pub use setup::{list_adapters, PresentPref};
|
||||
|
||||
/// One presenter iteration's video input.
|
||||
pub enum FrameInput<'a> {
|
||||
@@ -247,10 +247,75 @@ impl Presenter {
|
||||
/// (the presenter itself never sees them). No-op when timing is inactive.
|
||||
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
|
||||
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
|
||||
t.enqueue(sc, id, pts_ns, decoded_ns);
|
||||
// The submit stamp: `present()` already returned, so "now" is within the
|
||||
// present-call tail — the pace/latch split point.
|
||||
t.enqueue(
|
||||
sc,
|
||||
id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
pf_client_core::session::now_ns(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
|
||||
/// FIFO glass gate's budget count.
|
||||
pub(crate) fn presents_outstanding(&self) -> usize {
|
||||
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
|
||||
}
|
||||
|
||||
/// Install the run loop's wake for present completions (an SDL event push). No-op
|
||||
/// without present timing — there is nothing to wake on then.
|
||||
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
if let Some(t) = &self.present_timer {
|
||||
t.set_wake(cb);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live swapchain present mode, for the stats overlay: a mode is picked from
|
||||
/// what the surface actually offers, so the requested one and this can differ (a
|
||||
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
|
||||
/// driver, notably). Showing it is what makes that visible instead of puzzling.
|
||||
pub(crate) fn present_mode_name(&self) -> &'static str {
|
||||
match self.present_mode {
|
||||
vk::PresentModeKHR::MAILBOX => "mailbox",
|
||||
vk::PresentModeKHR::FIFO => "fifo",
|
||||
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
|
||||
vk::PresentModeKHR::IMMEDIATE => "immediate",
|
||||
setup::fifo_latest_ready::MODE => "fifo-latest-ready",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The active present mode QUEUES presents — the only modes where the swapchain
|
||||
/// itself can become a standing queue, and so the only ones the glass gate governs.
|
||||
///
|
||||
/// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does
|
||||
/// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it
|
||||
/// would hold frames back to emulate something the presentation engine is already
|
||||
/// doing, paying the serialisation twice.
|
||||
pub(crate) fn needs_glass_gate(&self) -> bool {
|
||||
matches!(
|
||||
self.present_mode,
|
||||
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
|
||||
)
|
||||
}
|
||||
|
||||
/// The active present mode shows images ON THE VBLANK GRID — the premise the VRR
|
||||
/// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole
|
||||
/// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but
|
||||
/// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them
|
||||
/// the probe reports Unknown rather than calling every session VRR.
|
||||
pub(crate) fn vblank_locked(&self) -> bool {
|
||||
matches!(
|
||||
self.present_mode,
|
||||
vk::PresentModeKHR::FIFO
|
||||
| vk::PresentModeKHR::FIFO_RELAXED
|
||||
| setup::fifo_latest_ready::MODE
|
||||
)
|
||||
}
|
||||
|
||||
/// Take the window's completed on-glass samples (empty when timing is inactive).
|
||||
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
|
||||
self.present_timer
|
||||
|
||||
@@ -26,6 +26,9 @@ pub(crate) struct PresentedSample {
|
||||
pub pts_ns: u64,
|
||||
/// Decode-complete stamp (client clock) — the display-stage anchor.
|
||||
pub decoded_ns: u64,
|
||||
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
|
||||
/// `submitted − decoded` is our pipeline, `displayed − submitted` the vsync latch.
|
||||
pub submitted_ns: u64,
|
||||
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
|
||||
pub displayed_ns: u64,
|
||||
}
|
||||
@@ -35,15 +38,24 @@ struct Job {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
}
|
||||
|
||||
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
|
||||
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
|
||||
|
||||
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
|
||||
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
|
||||
pub(crate) struct PresentTimer {
|
||||
tx: Option<mpsc::Sender<Job>>,
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
|
||||
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
|
||||
/// and the glass gate's "undisplayed presents in flight" count.
|
||||
pending: Arc<AtomicUsize>,
|
||||
results: Arc<Mutex<Vec<PresentedSample>>>,
|
||||
/// Called by the waiter after each completed wait (sample or not) — the run loop
|
||||
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
|
||||
/// event-loop timeout.
|
||||
wake: WakeSlot,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -52,7 +64,8 @@ impl PresentTimer {
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let pending = Arc::new(AtomicUsize::new(0));
|
||||
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
|
||||
let (pending_t, results_t) = (pending.clone(), results.clone());
|
||||
let wake: WakeSlot = Arc::new(Mutex::new(None));
|
||||
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-present-wait".into())
|
||||
.spawn(move || {
|
||||
@@ -69,12 +82,20 @@ impl PresentTimer {
|
||||
results_t.lock().unwrap().push(PresentedSample {
|
||||
pts_ns: job.pts_ns,
|
||||
decoded_ns: job.decoded_ns,
|
||||
submitted_ns: job.submitted_ns,
|
||||
displayed_ns,
|
||||
});
|
||||
}
|
||||
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
|
||||
// (or the loop is about to find out) — never poison the window.
|
||||
pending_t.fetch_sub(1, Ordering::AcqRel);
|
||||
// Wake the run loop AFTER the count dropped: what it observes on
|
||||
// wake is the post-completion state (the gate may now be open).
|
||||
// Called under the slot lock — the callback is a bare SDL event
|
||||
// push and never reenters this type.
|
||||
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn pf-present-wait");
|
||||
@@ -82,10 +103,23 @@ impl PresentTimer {
|
||||
tx: Some(tx),
|
||||
pending,
|
||||
results,
|
||||
wake,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
|
||||
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
|
||||
*self.wake.lock().unwrap() = Some(cb);
|
||||
}
|
||||
|
||||
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
|
||||
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
|
||||
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
|
||||
pub(crate) fn outstanding(&self) -> usize {
|
||||
self.pending.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Hand a successfully submitted present to the waiter.
|
||||
pub(crate) fn enqueue(
|
||||
&self,
|
||||
@@ -93,6 +127,7 @@ impl PresentTimer {
|
||||
present_id: u64,
|
||||
pts_ns: u64,
|
||||
decoded_ns: u64,
|
||||
submitted_ns: u64,
|
||||
) {
|
||||
if let Some(tx) = &self.tx {
|
||||
self.pending.fetch_add(1, Ordering::AcqRel);
|
||||
@@ -102,6 +137,7 @@ impl PresentTimer {
|
||||
present_id,
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
submitted_ns,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -13,10 +13,55 @@ use ash::vk;
|
||||
use ash::vk::Handle as _;
|
||||
use std::ffi::{c_char, CString};
|
||||
|
||||
/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers
|
||||
/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why
|
||||
/// an unenabled driver reports the mode back as the bare number `1000361000`.
|
||||
///
|
||||
/// The mode is FIFO's tear-free vblank pacing that presents the **latest ready** image at
|
||||
/// each refresh and retires the older ones, instead of draining a queue. That is precisely
|
||||
/// what [`super::super::present_pace::PresentGate`] emulates in software, done by the
|
||||
/// driver — and it matters most exactly where the gate does: on a surface that offers no
|
||||
/// MAILBOX, this restores newest-wins behaviour without the app holding frames back.
|
||||
pub(crate) mod fifo_latest_ready {
|
||||
use ash::vk;
|
||||
|
||||
/// `VK_EXT_present_mode_fifo_latest_ready` (extension 361).
|
||||
pub(super) const NAME: &std::ffi::CStr = c"VK_EXT_present_mode_fifo_latest_ready";
|
||||
/// `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.
|
||||
pub(crate) const MODE: vk::PresentModeKHR = vk::PresentModeKHR::from_raw(1000361000);
|
||||
/// `VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_MODE_FIFO_LATEST_READY_FEATURES_EXT`.
|
||||
const S_TYPE: vk::StructureType = vk::StructureType::from_raw(1000361000);
|
||||
|
||||
/// `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT`. The mode is usable only
|
||||
/// when this feature is enabled at device creation, so the surface advertising the
|
||||
/// mode is NOT on its own permission to request it.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Features {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *mut std::ffi::c_void,
|
||||
pub present_mode_fifo_latest_ready: vk::Bool32,
|
||||
}
|
||||
|
||||
impl Default for Features {
|
||||
fn default() -> Features {
|
||||
Features {
|
||||
s_type: S_TYPE,
|
||||
p_next: std::ptr::null_mut(),
|
||||
present_mode_fifo_latest_ready: vk::FALSE,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
/// Bring up instance → surface → device → swapchain over an SDL window.
|
||||
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
|
||||
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
|
||||
pub fn new(
|
||||
window: &sdl3::video::Window,
|
||||
instance_extensions: &[String],
|
||||
pref: PresentPref,
|
||||
) -> Result<Presenter> {
|
||||
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
|
||||
// builder structs that are locals outliving the call; the handle it returns is owned by
|
||||
// the value being built here.
|
||||
@@ -176,6 +221,21 @@ impl Presenter {
|
||||
// structs through its pNext chain, so any later use of it would pin those borrows —
|
||||
// every read of a chained struct below must come after this, have_f2's last use.
|
||||
let have_shader_int16 = have_f2.features.shader_int16;
|
||||
// FIFO_LATEST_READY: the surface may list the mode even with the extension
|
||||
// disabled, so the device feature is the real gate on using it.
|
||||
let flr_ok = if has(fifo_latest_ready::NAME) {
|
||||
let mut feat = fifo_latest_ready::Features::default();
|
||||
let mut probe = vk::PhysicalDeviceFeatures2 {
|
||||
p_next: (&mut feat) as *mut _ as *mut std::ffi::c_void,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: per the Vulkan contract above - a read-only query on the live
|
||||
// instance/device, filling locals returned by value; `feat` outlives the call.
|
||||
unsafe { instance.get_physical_device_features2(pdev, &mut probe) };
|
||||
feat.present_mode_fifo_latest_ready == vk::TRUE
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let present_wait_ok = present_wait_exts
|
||||
&& have_pid.present_id == vk::TRUE
|
||||
&& have_pwait.present_wait == vk::TRUE;
|
||||
@@ -273,6 +333,13 @@ impl Presenter {
|
||||
dev_exts.push(ash::khr::present_id::NAME.as_ptr());
|
||||
dev_exts.push(ash::khr::present_wait::NAME.as_ptr());
|
||||
}
|
||||
if flr_ok {
|
||||
dev_exts.push(fifo_latest_ready::NAME.as_ptr());
|
||||
}
|
||||
let mut en_flr = fifo_latest_ready::Features {
|
||||
present_mode_fifo_latest_ready: vk::TRUE,
|
||||
..Default::default()
|
||||
};
|
||||
let mut en_pid = vk::PhysicalDevicePresentIdFeaturesKHR::default().present_id(true);
|
||||
let mut en_pwait = vk::PhysicalDevicePresentWaitFeaturesKHR::default().present_wait(true);
|
||||
|
||||
@@ -295,6 +362,11 @@ impl Presenter {
|
||||
if present_wait_ok {
|
||||
en_f2 = en_f2.push_next(&mut en_pid).push_next(&mut en_pwait);
|
||||
}
|
||||
if flr_ok {
|
||||
// Hand-rolled struct, so chain it by hand: splice into the pNext list head.
|
||||
en_flr.p_next = en_f2.p_next;
|
||||
en_f2.p_next = (&mut en_flr) as *mut _ as *mut std::ffi::c_void;
|
||||
}
|
||||
en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE };
|
||||
|
||||
let priorities = [1.0f32];
|
||||
@@ -450,11 +522,17 @@ impl Presenter {
|
||||
if let Some(v) = video_export.as_mut() {
|
||||
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
|
||||
}
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
||||
let mut pref = pref;
|
||||
pref.vrr_fifo_opt_in = vrr_fifo_opt_in();
|
||||
pref.fifo_latest_ready = flr_ok;
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?;
|
||||
tracing::info!(
|
||||
?format,
|
||||
?hdr10_format,
|
||||
?present_mode,
|
||||
vsync = pref.vsync,
|
||||
allow_vrr = pref.allow_vrr,
|
||||
fifo_latest_ready = flr_ok,
|
||||
hdr_metadata = has_hdr_metadata,
|
||||
"swapchain config"
|
||||
);
|
||||
@@ -730,42 +808,275 @@ pub(super) fn pick_formats(
|
||||
Ok((sdr, hdr10))
|
||||
}
|
||||
|
||||
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
|
||||
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
|
||||
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
|
||||
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
|
||||
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
|
||||
/// What the user asked the presentation to be, resolved into a swapchain present mode by
|
||||
/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PresentPref {
|
||||
/// Tear-free presentation (the `vsync` setting, default on).
|
||||
pub vsync: bool,
|
||||
/// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on).
|
||||
pub allow_vrr: bool,
|
||||
/// Opt-in for the VRR FIFO-first ladder (`PUNKTFUNK_VRR_FIFO=1`). Off by default on
|
||||
/// measured evidence — see [`present_mode_chain`].
|
||||
pub vrr_fifo_opt_in: bool,
|
||||
/// `VK_EXT_present_mode_fifo_latest_ready` is enabled on the device, so the mode may
|
||||
/// be requested. Resolved during device creation; never set by callers.
|
||||
pub fifo_latest_ready: bool,
|
||||
/// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so
|
||||
/// this is the starting state and an F11 mid-session does not re-pick — consistent
|
||||
/// with the shells' "Display changes apply from the next session" footer, and why
|
||||
/// live present-mode switching is an explicit non-goal.
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// The preference ladder, most to least wanted. The caller takes the first entry the
|
||||
/// surface actually offers; FIFO ends every chain because the spec guarantees it.
|
||||
///
|
||||
/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on
|
||||
/// a late frame), then the tear-free modes. Asking for tearing and silently getting
|
||||
/// vsync is a lie the stats line now exposes, but the ladder still degrades safely.
|
||||
/// * **V-Sync on + VRR allowed + fullscreen + `PUNKTFUNK_VRR_FIFO=1`** — FIFO first. On a
|
||||
/// variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel
|
||||
/// follows the stream's cadence; MAILBOX would decouple presents from scanout and
|
||||
/// re-quantize to the compositor's clock.
|
||||
///
|
||||
/// **Automatic where a queue-free vblank mode exists, opt-in otherwise.** The history is
|
||||
/// worth keeping: this was default-on, then measured on glass (.21, GNOME/Wayland,
|
||||
/// NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) to cost ~27 ms of display stage against
|
||||
/// MAILBOX — `28.4 ms (pace 11.8 + latch 16.6)` versus `1.4 ms (0.2 + 1.2)` — because a
|
||||
/// plain-FIFO present's on-glass confirmation lands a whole refresh later and the
|
||||
/// presenter serialises behind it. It became opt-in on that evidence.
|
||||
///
|
||||
/// `FIFO_LATEST_READY` removes the cause rather than working around it: the driver
|
||||
/// retires stale images, so the vblank-locked path measured **2.6 ms** on the same box —
|
||||
/// 0.6 ms over MAILBOX instead of 27. So where the device offers it, following the panel
|
||||
/// is cheap enough to be the default again; where it does not, the ladder would fall
|
||||
/// back to plain FIFO and the regression returns, so it stays behind
|
||||
/// `PUNKTFUNK_VRR_FIFO=1` there. The win on a genuine VRR panel is still UNMEASURED —
|
||||
/// no VRR display was available — but the cost of trying is now small and bounded.
|
||||
/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more
|
||||
/// than the newest frame, so an arrival-paced presenter doesn't block in the present
|
||||
/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images
|
||||
/// for a vblank pass, or when arrival cadence drifts against refresh).
|
||||
///
|
||||
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
|
||||
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
|
||||
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
|
||||
/// cadence is often — a trade the user must choose, never a silent fallback.
|
||||
/// expected, not a misconfiguration, and now visible in the `present:` stats line.
|
||||
fn present_mode_chain(pref: PresentPref) -> Vec<vk::PresentModeKHR> {
|
||||
use vk::PresentModeKHR as M;
|
||||
let flr = pref.fifo_latest_ready.then_some(fifo_latest_ready::MODE);
|
||||
let mut chain: Vec<M> = if !pref.vsync {
|
||||
vec![M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX]
|
||||
} else if pref.allow_vrr && pref.fullscreen && (pref.fifo_latest_ready || pref.vrr_fifo_opt_in)
|
||||
{
|
||||
// The VRR ladder wants the vblank-locked family; LATEST_READY is that with the
|
||||
// queue removed, so it outranks plain FIFO here too.
|
||||
vec![]
|
||||
.into_iter()
|
||||
.chain(flr)
|
||||
.chain([M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE])
|
||||
.collect()
|
||||
} else {
|
||||
// MAILBOX first (measured good), then LATEST_READY — which is what gives a
|
||||
// MAILBOX-less surface the same newest-wins behaviour, in the driver instead of
|
||||
// in our glass gate.
|
||||
vec![M::MAILBOX]
|
||||
.into_iter()
|
||||
.chain(flr)
|
||||
.chain([M::FIFO_RELAXED, M::IMMEDIATE])
|
||||
.collect()
|
||||
};
|
||||
if !pref.vsync {
|
||||
chain.extend(flr);
|
||||
}
|
||||
// FIFO ends every chain: the spec guarantees it exists, so there is always a landing.
|
||||
chain.push(M::FIFO);
|
||||
chain
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels.
|
||||
/// See [`present_mode_chain`] for the measurement that made this opt-in rather than
|
||||
/// default.
|
||||
fn vrr_fifo_opt_in() -> bool {
|
||||
std::env::var("PUNKTFUNK_VRR_FIFO").is_ok_and(|v| v != "0")
|
||||
}
|
||||
|
||||
/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever,
|
||||
/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers.
|
||||
fn pick_present_mode(
|
||||
surface_i: &ash::khr::surface::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
surface: vk::SurfaceKHR,
|
||||
pref: PresentPref,
|
||||
) -> Result<vk::PresentModeKHR> {
|
||||
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
|
||||
// filling locals returned by value.
|
||||
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
|
||||
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => vk::PresentModeKHR::FIFO,
|
||||
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
|
||||
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
|
||||
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
|
||||
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
|
||||
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
|
||||
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
|
||||
None => None,
|
||||
Some(other) => {
|
||||
tracing::warn!(
|
||||
value = other,
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
|
||||
);
|
||||
vk::PresentModeKHR::MAILBOX
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(if modes.contains(&want) {
|
||||
want
|
||||
} else {
|
||||
vk::PresentModeKHR::FIFO // always available per spec
|
||||
})
|
||||
if let Some(want) = pinned {
|
||||
if modes.contains(&want) {
|
||||
return Ok(want);
|
||||
}
|
||||
tracing::warn!(
|
||||
?want,
|
||||
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
|
||||
);
|
||||
}
|
||||
// What the surface ACTUALLY offers, logged unconditionally. "AMD's Windows driver
|
||||
// has no MAILBOX" is the premise the FIFO glass gate is built on, and it has been
|
||||
// carried in comments rather than measured — present modes are a property of the
|
||||
// (surface, device) pair, so they vary by platform surface, driver version and
|
||||
// fullscreen state, and the only way to settle it is to read it back from real
|
||||
// machines. One line here makes every field log answer the question.
|
||||
tracing::info!(
|
||||
available = ?modes,
|
||||
"surface present modes"
|
||||
);
|
||||
let chain = present_mode_chain(pref);
|
||||
let chosen = chain
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|m| modes.contains(m))
|
||||
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
|
||||
// The one line that answers "did V-Sync off actually take?" — a request the surface
|
||||
// can't serve is a fact about the driver, and it must not look like our choice.
|
||||
if chosen != chain[0] {
|
||||
tracing::info!(
|
||||
requested = ?chain[0],
|
||||
active = ?chosen,
|
||||
vsync = pref.vsync,
|
||||
allow_vrr = pref.allow_vrr,
|
||||
"the surface does not offer the preferred present mode"
|
||||
);
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vk::PresentModeKHR as M;
|
||||
|
||||
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
|
||||
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
|
||||
/// have no landing.
|
||||
#[test]
|
||||
fn present_mode_chains_rank_by_intent() {
|
||||
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
|
||||
vsync,
|
||||
allow_vrr,
|
||||
fullscreen,
|
||||
vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below)
|
||||
fifo_latest_ready: false,
|
||||
};
|
||||
let flr = fifo_latest_ready::MODE;
|
||||
|
||||
// V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing
|
||||
// already gives a VRR-like latch, so the two never fight).
|
||||
assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE);
|
||||
assert_eq!(
|
||||
present_mode_chain(pref(false, false, false))[0],
|
||||
M::IMMEDIATE
|
||||
);
|
||||
assert_eq!(
|
||||
present_mode_chain(pref(false, true, true))[1],
|
||||
M::FIFO_RELAXED,
|
||||
"tears only on a late frame — the gentler tearing rung"
|
||||
);
|
||||
|
||||
// Tear-free + VRR allowed + fullscreen prefers the vblank-locked family — but
|
||||
// ONLY when opted in.
|
||||
assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO);
|
||||
// Without the opt-in the shipped MAILBOX-first default stands: measured on glass
|
||||
// to be ~27 ms of display stage better on a non-VRR panel.
|
||||
assert_eq!(
|
||||
present_mode_chain(PresentPref {
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
fullscreen: true,
|
||||
vrr_fifo_opt_in: false,
|
||||
fifo_latest_ready: false,
|
||||
})[0],
|
||||
M::MAILBOX,
|
||||
"without a queue-free vblank mode the VRR ladder would lead with plain FIFO, \
|
||||
which measured ~27 ms worse — so it stays opt-in there"
|
||||
);
|
||||
assert_eq!(
|
||||
present_mode_chain(PresentPref {
|
||||
vsync: true,
|
||||
allow_vrr: true,
|
||||
fullscreen: true,
|
||||
vrr_fifo_opt_in: false,
|
||||
fifo_latest_ready: true,
|
||||
})[0],
|
||||
fifo_latest_ready::MODE,
|
||||
"with LATEST_READY available, following the panel costs 0.6 ms over MAILBOX \
|
||||
instead of 27 — cheap enough to be automatic"
|
||||
);
|
||||
|
||||
// FIFO_LATEST_READY only appears where the device enabled it, and it outranks
|
||||
// plain FIFO everywhere: it is FIFO's vblank pacing WITHOUT the queue, which is
|
||||
// what a MAILBOX-less surface otherwise needs the software glass gate for.
|
||||
let with_flr = |vsync, allow_vrr, fullscreen| PresentPref {
|
||||
vsync,
|
||||
allow_vrr,
|
||||
fullscreen,
|
||||
vrr_fifo_opt_in: true,
|
||||
fifo_latest_ready: true,
|
||||
};
|
||||
for p in [
|
||||
pref(true, false, false),
|
||||
pref(true, true, true),
|
||||
pref(false, true, true),
|
||||
] {
|
||||
assert!(
|
||||
!present_mode_chain(p).contains(&flr),
|
||||
"never requested unless the device enabled the extension"
|
||||
);
|
||||
}
|
||||
let default_flr = present_mode_chain(with_flr(true, false, false));
|
||||
assert_eq!(
|
||||
default_flr[0],
|
||||
M::MAILBOX,
|
||||
"MAILBOX still leads by measurement"
|
||||
);
|
||||
assert_eq!(default_flr[1], flr, "then the driver-native newest-wins");
|
||||
assert!(
|
||||
default_flr.iter().position(|m| *m == flr)
|
||||
< default_flr.iter().position(|m| *m == M::FIFO),
|
||||
"LATEST_READY must outrank plain FIFO — it is FIFO minus the standing queue"
|
||||
);
|
||||
assert_eq!(
|
||||
present_mode_chain(with_flr(true, true, true))[0],
|
||||
flr,
|
||||
"the VRR ladder takes the queue-free vblank mode first"
|
||||
);
|
||||
|
||||
// Every ladder can land: FIFO appears in all of them.
|
||||
for p in [
|
||||
pref(true, true, true),
|
||||
pref(true, true, false),
|
||||
pref(true, false, true),
|
||||
pref(false, true, true),
|
||||
pref(false, false, false),
|
||||
with_flr(true, false, false),
|
||||
] {
|
||||
assert!(
|
||||
present_mode_chain(p).contains(&M::FIFO),
|
||||
"FIFO is the guaranteed landing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"AXIS_RT" = "PUNKTFUNK_AXIS_RT"
|
||||
"AUDIO_MAGIC" = "PUNKTFUNK_AUDIO_MAGIC"
|
||||
"RUMBLE_MAGIC" = "PUNKTFUNK_RUMBLE_MAGIC"
|
||||
"AUDIO_RED_MAGIC" = "PUNKTFUNK_AUDIO_RED_MAGIC"
|
||||
"AUDIO_RED_HEADER" = "PUNKTFUNK_AUDIO_RED_HEADER"
|
||||
# Same hazard as the BTN_* block above, one step worse: `FRAME_MS` and `SAMPLE_RATE_HZ` are
|
||||
# generic enough that an embedder is likely to have its own, and a clashing #define silently
|
||||
# takes the last definition rather than failing to compile.
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
|
||||
+469
-108
@@ -24,17 +24,19 @@
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
//! (heavy-but-recoverable loss, an OWD rise, a decode rise) needs two consecutive bad windows.
|
||||
//! Recovery is two-mode: **slow start** — until the first congestion signal the rate DOUBLES each
|
||||
//! clean window (cooldown-paced), which is how an Automatic session climbs from the conservative
|
||||
//! start to the [`set_ceiling`](BitrateController::set_ceiling) measured by the startup
|
||||
//! link-capacity probe in seconds instead of minutes — then classic additive recovery (+~6 %
|
||||
//! after ~4.5 s clean, ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! Recovery is two-mode: **slow start** — until the first congestion signal each clean window
|
||||
//! asks for double the current rate, bounded (like every climb) by the proven-throughput
|
||||
//! headroom below, so the step a loaded session actually takes is ×1.5 over what it last
|
||||
//! delivered; either way it climbs from the conservative start to the
|
||||
//! [`set_ceiling`](BitrateController::set_ceiling) measured by the startup link-capacity probe
|
||||
//! in seconds rather than minutes — then classic additive recovery (+~6 % after ~4.5 s clean,
|
||||
//! ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! rebuilt encoder opens with) and the whole controller disables itself against a host that never
|
||||
//! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages).
|
||||
//! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the
|
||||
//! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate
|
||||
//! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly
|
||||
//! ([`CAP_REPROBE_WINDOWS`]) so neither latch outlives the condition that taught it.
|
||||
//! ([`CAP_REPROBE_WINDOWS_MIN`]) so neither latch outlives the condition that taught it.
|
||||
//!
|
||||
//! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder —
|
||||
//! how many bits it actually emits depends on the content — so on calm content (a menu, an idle
|
||||
@@ -128,15 +130,26 @@ const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
|
||||
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same clock for the
|
||||
/// same reason: the decoder's knee moves with content and thermals, so its latch must not be
|
||||
/// permanent either.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
|
||||
/// interval backs off to.
|
||||
///
|
||||
/// A learned cap is EVIDENCE, not a spec limit: the host's short ack means "not right now",
|
||||
/// which covers both its encoder's codec-level ceiling (durable) and a climb refused while
|
||||
/// encode is behind cadence (transient, and routinely latched during slow start at the
|
||||
/// conservative 20 Mbps default). The client cannot tell those apart from the ack alone, so the
|
||||
/// re-probe is what keeps a transient from becoming the session's ceiling — and a flat ~60 s
|
||||
/// clock at +12.5 % made that escape take upwards of twenty minutes to cross the gap to a
|
||||
/// probe-measured link ceiling, which is indistinguishable from never.
|
||||
///
|
||||
/// So: probe again after 12 s, and DOUBLE the interval each time the lift is immediately
|
||||
/// re-learned at the same value (see [`on_ack`](BitrateController::on_ack)). A transient is out
|
||||
/// in one interval; a standing limit settles into a slow poll instead of a permanent one. The
|
||||
/// re-probe itself is nearly free either way — a still-standing limit re-teaches itself in two
|
||||
/// short acks, which the host pre-clamps without touching the encoder: no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same schedule for
|
||||
/// the same reason: the decoder's knee moves with content and thermals.
|
||||
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
|
||||
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// Two consecutive decode-driven backoffs latch the
|
||||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||||
@@ -146,6 +159,17 @@ const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// 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;
|
||||
/// Windows a rolling baseline must hold before the signal it feeds may fire. A baseline is a
|
||||
/// rolling MINIMUM, so a single sample IS the baseline — and if that one window landed on calm
|
||||
/// content, ordinary content variance clears the rise threshold by itself. That hole is not
|
||||
/// theoretical: [`on_ack`](BitrateController::on_ack) deliberately CLEARS the encode baseline
|
||||
/// after every decrease we ourselves asked for, so the encode down-driver re-armed on a
|
||||
/// one-sample floor each time — a calm re-seed window followed by a motion scene reads as
|
||||
/// `ENCODE_RISE_US` of "congestion", backs off, clears again, and ratchets to the floor on a
|
||||
/// link that was never the problem. Four windows (3 s) of evidence before any of the three
|
||||
/// latency signals may fire costs a little reaction latency at session start and buys a floor
|
||||
/// that means something.
|
||||
const BASELINE_MIN_WINDOWS: usize = 4;
|
||||
/// Requests sent without a single [`crate::quic::BitrateChanged`] ack before concluding the host
|
||||
/// predates bitrate renegotiation and going quiet for the rest of the session.
|
||||
const MAX_UNACKED: u32 = 3;
|
||||
@@ -167,6 +191,37 @@ fn ceiling_cap_from_env() -> Option<u32> {
|
||||
.map(|m| m.saturating_mul(1_000))
|
||||
}
|
||||
|
||||
/// Score one window's latency sample against its rolling-min baseline, then record it.
|
||||
///
|
||||
/// Shared by all three latency signals (OWD, client decode, host encode) — same shape, different
|
||||
/// thresholds. `mean` is `None` when nobody reports the signal (no clock handshake, an embedder
|
||||
/// that doesn't measure decode, a host that ships no stage timings); the signal is then simply
|
||||
/// absent rather than clean, so it can neither mark a window bad nor teach a baseline.
|
||||
///
|
||||
/// The baseline is the minimum of the PRIOR windows — this window is compared before it is
|
||||
/// recorded, so a rising window can't drag its own floor up with it — and only counts once
|
||||
/// [`BASELINE_MIN_WINDOWS`] of them exist. Returns `(rise, severe)`; pass `i64::MAX` for
|
||||
/// `severe_us` on a signal with no severe tier.
|
||||
fn score_baseline(
|
||||
means: &mut VecDeque<i64>,
|
||||
mean: Option<i64>,
|
||||
rise_us: i64,
|
||||
severe_us: i64,
|
||||
) -> (bool, bool) {
|
||||
let Some(mean) = mean else {
|
||||
return (false, false);
|
||||
};
|
||||
let base = (means.len() >= BASELINE_MIN_WINDOWS)
|
||||
.then(|| means.iter().min().copied())
|
||||
.flatten();
|
||||
let over = |t: i64| base.is_some_and(|b| mean > b.saturating_add(t));
|
||||
if means.len() == BASELINE_WINDOWS {
|
||||
means.pop_front();
|
||||
}
|
||||
means.push_back(mean);
|
||||
(over(rise_us), over(severe_us))
|
||||
}
|
||||
|
||||
/// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`].
|
||||
pub(crate) struct BitrateController {
|
||||
/// `false` = permanently off (explicit user bitrate, an old host, or ack silence).
|
||||
@@ -199,7 +254,7 @@ pub(crate) struct BitrateController {
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||||
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
|
||||
/// ([`CAP_REPROBE_WINDOWS_MIN`]) so scene-dependent evidence can't cap the session forever.
|
||||
host_cap_kbps: Option<u32>,
|
||||
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||||
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||||
@@ -210,8 +265,11 @@ pub(crate) struct BitrateController {
|
||||
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||||
short_ack_kbps: u32,
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock) and the interval it is
|
||||
/// counting toward — [`CAP_REPROBE_WINDOWS_MIN`], doubled toward
|
||||
/// [`CAP_REPROBE_WINDOWS_MAX`] each time a lift is immediately re-learned.
|
||||
cap_probe_windows: u32,
|
||||
cap_reprobe_after: u32,
|
||||
/// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the
|
||||
/// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe
|
||||
/// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the
|
||||
@@ -220,7 +278,7 @@ pub(crate) struct BitrateController {
|
||||
/// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling
|
||||
/// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the
|
||||
/// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed
|
||||
/// on the [`CAP_REPROBE_WINDOWS`] clock, exactly like the host cap, so a decoder that
|
||||
/// on the [`CAP_REPROBE_WINDOWS_MIN`] clock, exactly like the host cap, so a decoder that
|
||||
/// recovers (lighter content, thermal headroom) climbs again — the latch is never
|
||||
/// permanent.
|
||||
decode_cap_kbps: Option<u32>,
|
||||
@@ -228,8 +286,10 @@ pub(crate) struct BitrateController {
|
||||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||||
/// to latch the cap — one spurious flush teaches nothing.
|
||||
decode_backoff_kbps: u32,
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock).
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
|
||||
/// clock's own backoff interval — same schedule as the host cap's.
|
||||
decode_cap_probe_windows: u32,
|
||||
decode_cap_reprobe_after: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -241,6 +301,10 @@ pub(crate) struct BitrateController {
|
||||
last_change: Option<Instant>,
|
||||
/// Requests since the last ack — reaching [`MAX_UNACKED`] disables the controller.
|
||||
unacked: u32,
|
||||
/// The last ceiling-clamp target asked for (0 = none). A session running ABOVE its effective
|
||||
/// ceiling is asked down to it exactly once per distinct target — a host that answers higher
|
||||
/// has said it cannot go there, and re-asking every cooldown only costs reconfigures.
|
||||
ceiling_ask_kbps: u32,
|
||||
}
|
||||
|
||||
impl BitrateController {
|
||||
@@ -257,7 +321,12 @@ impl BitrateController {
|
||||
BitrateController {
|
||||
enabled: start_kbps > 0,
|
||||
current_kbps: start_kbps,
|
||||
ceiling_kbps: start_kbps,
|
||||
// The env cap binds the NEGOTIATED ceiling too, not just probe-learned ones. It is
|
||||
// the only lever an Automatic session gives the operator (Automatic is precisely
|
||||
// "no explicit bitrate"), so a start rate above it has to come down rather than
|
||||
// stand as a ceiling the user asked not to reach — see the clamp-down step in
|
||||
// [`on_window`](Self::on_window).
|
||||
ceiling_kbps: start_kbps.min(ceiling_cap_kbps.unwrap_or(u32::MAX)),
|
||||
ceiling_cap_kbps,
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
@@ -269,14 +338,17 @@ impl BitrateController {
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
decode_cap_kbps: None,
|
||||
decode_backoff_kbps: 0,
|
||||
decode_cap_probe_windows: 0,
|
||||
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
last_change: None,
|
||||
unacked: 0,
|
||||
ceiling_ask_kbps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +393,21 @@ impl BitrateController {
|
||||
self.short_acks = 1;
|
||||
}
|
||||
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||||
// Re-learning a cap we had already lifted means the limit is STANDING,
|
||||
// not the transient the re-probe exists to escape — back its clock off
|
||||
// (see [`CAP_REPROBE_WINDOWS_MIN`]) so a hard encoder ceiling settles
|
||||
// into a slow poll instead of two pointless acks every 12 s. A first
|
||||
// latch starts the clock fast, because that is the case that matters.
|
||||
self.cap_reprobe_after = if self.host_cap_kbps.is_some() {
|
||||
self.cap_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
tracing::info!(
|
||||
cap_kbps = kbps,
|
||||
reprobe_after_windows = self.cap_reprobe_after,
|
||||
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||||
refusal) — climbs stop here until it lifts"
|
||||
);
|
||||
@@ -331,9 +416,34 @@ impl BitrateController {
|
||||
}
|
||||
} else {
|
||||
self.short_acks = 0;
|
||||
// GRANTED in full at or above the learned cap: the limit that taught it is
|
||||
// gone, and we have the host's own word for it. Drop the cap outright rather
|
||||
// than keep crawling up in +12.5 % re-probe steps — for a cap latched from a
|
||||
// transient (a host briefly behind cadence) that crawl is the entire
|
||||
// remaining cost of the transient, and it is measured in minutes.
|
||||
if self.host_cap_kbps.is_some_and(|c| kbps >= c) {
|
||||
tracing::info!(
|
||||
granted_kbps = kbps,
|
||||
"adaptive bitrate: host granted a climb at the learned cap — the \
|
||||
limit has lifted, dropping it"
|
||||
);
|
||||
self.host_cap_kbps = None;
|
||||
self.cap_probe_windows = 0;
|
||||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current_kbps = kbps;
|
||||
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
|
||||
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
|
||||
// encodes (a 1080p session mirroring a 4K panel resolves ~3× higher), and that is
|
||||
// the host's own Automatic answer, not a climb we asked for. Let the ceiling follow
|
||||
// — `set_ceiling` only ever raises, and still clamps to the operator's
|
||||
// `PUNKTFUNK_ABR_MAX_MBPS`, which is what must bind here if anything does. Without
|
||||
// this the ceiling stays at the stale negotiated rate and the step-down below
|
||||
// immediately drags the host back off the rate it just chose. A no-op for ordinary
|
||||
// acks: we never request above the effective ceiling in the first place.
|
||||
self.set_ceiling(kbps);
|
||||
}
|
||||
self.unacked = 0;
|
||||
}
|
||||
@@ -343,14 +453,30 @@ impl BitrateController {
|
||||
/// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so
|
||||
/// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property)
|
||||
/// survives.
|
||||
///
|
||||
/// Every rolling BASELINE is mode-scoped too, and for the same reason the encode one always
|
||||
/// was: a mode switch changes what "normal" costs at both ends of the pipe. 4K120 decodes
|
||||
/// and encodes far slower than 1080p60 and puts bigger frames on the wire, so a baseline
|
||||
/// learned under the old mode is a floor the new one clears on its very first window —
|
||||
/// [`DECODE_RISE_US`] is 15 µs-thousands, well inside the gap between those two modes. Left
|
||||
/// standing (only `encode_means` used to be cleared here), the ~30 s it takes
|
||||
/// [`BASELINE_WINDOWS`] to age out is ~30 s of every window scoring bad, which is a ×0.7
|
||||
/// backoff every other window: a switch UP in mode cratered the rate instead of raising it.
|
||||
/// `proven_kbps` goes with them — it is the mark climbs are bounded against, and throughput
|
||||
/// the OLD mode's decoder digested is not evidence about this one. It re-earns itself from
|
||||
/// the next window.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
self.decode_cap_kbps = None;
|
||||
self.decode_backoff_kbps = 0;
|
||||
self.decode_cap_probe_windows = 0;
|
||||
self.owd_means.clear();
|
||||
self.decode_means.clear();
|
||||
self.encode_means.clear();
|
||||
self.proven_kbps = 0;
|
||||
}
|
||||
|
||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||
@@ -389,22 +515,9 @@ impl BitrateController {
|
||||
return None;
|
||||
}
|
||||
// OWD: compare against the rolling-min baseline of PRIOR windows (so a rising window
|
||||
// doesn't drag its own baseline up), then record it.
|
||||
let owd_bad = match owd_mean_us {
|
||||
Some(mean) => {
|
||||
let bad = self
|
||||
.owd_means
|
||||
.iter()
|
||||
.min()
|
||||
.is_some_and(|&base| mean > base + OWD_RISE_US);
|
||||
if self.owd_means.len() == BASELINE_WINDOWS {
|
||||
self.owd_means.pop_front();
|
||||
}
|
||||
self.owd_means.push_back(mean);
|
||||
bad
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// doesn't drag its own baseline up), then record it. No severe tier — a standing queue is
|
||||
// congestion evidence, not visible damage, so it always takes the two-window path.
|
||||
let (owd_bad, _) = score_baseline(&mut self.owd_means, owd_mean_us, OWD_RISE_US, i64::MAX);
|
||||
// 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
|
||||
@@ -412,43 +525,22 @@ impl BitrateController {
|
||||
// the link ceiling) and, sustained, drives the ×0.7 backoff down to the real decode limit.
|
||||
// An excursion far past baseline is SEVERE: the decoder is deep in spike-overload and the
|
||||
// user is watching it — skip the two-window confirmation.
|
||||
let (decode_bad, decode_severe) = match decode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.decode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + DECODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + DECODE_SEVERE_US);
|
||||
if self.decode_means.len() == BASELINE_WINDOWS {
|
||||
self.decode_means.pop_front();
|
||||
}
|
||||
self.decode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
let (decode_bad, decode_severe) = score_baseline(
|
||||
&mut self.decode_means,
|
||||
decode_mean_us,
|
||||
DECODE_RISE_US,
|
||||
DECODE_SEVERE_US,
|
||||
);
|
||||
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||||
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||||
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||||
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||||
let (encode_bad, encode_severe) = match encode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.encode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
|
||||
if self.encode_means.len() == BASELINE_WINDOWS {
|
||||
self.encode_means.pop_front();
|
||||
}
|
||||
self.encode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
||||
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
||||
// is the bad/severe machinery's business.
|
||||
if !decode_bad && actual_kbps > self.proven_kbps {
|
||||
self.proven_kbps = actual_kbps;
|
||||
}
|
||||
let (encode_bad, encode_severe) = score_baseline(
|
||||
&mut self.encode_means,
|
||||
encode_mean_us,
|
||||
ENCODE_RISE_US,
|
||||
ENCODE_SEVERE_US,
|
||||
);
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||||
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
|
||||
@@ -466,6 +558,17 @@ impl BitrateController {
|
||||
|| decode_bad
|
||||
|| encode_bad
|
||||
|| recovery_kf >= RECOVERY_KF_BAD;
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible — the pipeline carried it and NOTHING went wrong while it did. Scored after
|
||||
// the verdict and gated on the whole of it, not on decode alone: the mark never decays, so
|
||||
// one window is permanent authority over how far every later climb may step, and the
|
||||
// windows that overstate delivered throughput are exactly the damaged ones (a stall's
|
||||
// backlog draining in a single window, a flush's queue, the FEC surge that answers a loss
|
||||
// burst). "Loss doesn't disqualify, the bytes still arrived" was true about the bytes and
|
||||
// wrong about the conclusion drawn from them.
|
||||
if !bad && actual_kbps > self.proven_kbps {
|
||||
self.proven_kbps = actual_kbps;
|
||||
}
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -475,16 +578,16 @@ impl BitrateController {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
}
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
|
||||
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
|
||||
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
|
||||
// just re-latches from the next pair of short acks, at zero encoder cost.
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
|
||||
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
|
||||
// refusal can't quietly cap the whole session — a still-standing limit just re-latches
|
||||
// from the next pair of short acks, at zero encoder cost, and backs the clock off.
|
||||
if let Some(cap) = self.host_cap_kbps {
|
||||
if bad {
|
||||
self.cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.cap_probe_windows += 1;
|
||||
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
if self.cap_probe_windows >= self.cap_reprobe_after {
|
||||
self.cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
@@ -508,7 +611,7 @@ impl BitrateController {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.decode_cap_probe_windows += 1;
|
||||
if self.decode_cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
if self.decode_cap_probe_windows >= self.decode_cap_reprobe_after {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
@@ -538,18 +641,42 @@ impl BitrateController {
|
||||
// knee. One event never latches (a spurious flush must stay a one-off), and a
|
||||
// backoff without decode evidence in between breaks the streak — whatever it saw,
|
||||
// it wasn't the same knee.
|
||||
if decode_severe || flushed {
|
||||
// A bare flush counts as decode evidence only where the decode signal can't speak
|
||||
// for itself. On an embedder that reports decode latency, a flush with FLAT decode
|
||||
// is a network event (a stall, a clock step) that drained a queue the decoder was
|
||||
// keeping up with — teaching a "decoder knee" from it caps the session on the wrong
|
||||
// end of the pipe. Where the signal is absent the old reading stands: the flush is
|
||||
// the only decoder-saturation evidence there is.
|
||||
let decode_evidence =
|
||||
decode_severe || (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||||
<= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV;
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| rate < c) {
|
||||
// Latch just UNDER the rate that choked, not at it: the knee is the rate the
|
||||
// decoder could not hold, so a cap sitting exactly on it authorizes climbing
|
||||
// straight back into the failure — the sawtooth the cap exists to end, merely
|
||||
// slower. One sixteenth is inside the ±1/8 band the pair had to agree within,
|
||||
// so it costs nothing the evidence actually established.
|
||||
let knee = rate.saturating_sub(rate / 16).max(self.floor_kbps);
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| knee < c) {
|
||||
// Same standing-vs-transient backoff as the host cap.
|
||||
self.decode_cap_reprobe_after = if self.decode_cap_kbps.is_some() {
|
||||
self.decode_cap_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
tracing::info!(
|
||||
cap_kbps = rate,
|
||||
cap_kbps = knee,
|
||||
choked_at_kbps = rate,
|
||||
reprobe_after_windows = self.decode_cap_reprobe_after,
|
||||
"adaptive bitrate: decode cap learned (decoder knee) — climbs stop \
|
||||
here until it lifts"
|
||||
);
|
||||
self.decode_cap_kbps = Some(rate.max(self.floor_kbps));
|
||||
self.decode_cap_kbps = Some(knee);
|
||||
self.decode_cap_probe_windows = 0;
|
||||
}
|
||||
self.decode_backoff_kbps = rate;
|
||||
@@ -574,6 +701,23 @@ impl BitrateController {
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
|
||||
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
|
||||
// Above the ceiling with nothing wrong: the session negotiated a rate the operator's
|
||||
// `PUNKTFUNK_ABR_MAX_MBPS` forbids (no congestion signal will ever find this — the link
|
||||
// is fine, the cap is a policy). Step straight to it rather than sitting above a limit
|
||||
// the user set, and never below the floor. Asked ONCE per distinct target: if the host
|
||||
// answers with something higher it has told us it cannot go there (its own floor, an
|
||||
// encoder minimum), and repeating the ask every cooldown would buy nothing but a
|
||||
// reconfigure each time.
|
||||
let ceiling_target = eff_ceiling.max(self.floor_kbps);
|
||||
if self.current_kbps > ceiling_target && self.ceiling_ask_kbps != ceiling_target {
|
||||
tracing::info!(
|
||||
from_kbps = self.current_kbps,
|
||||
to_kbps = ceiling_target,
|
||||
"adaptive bitrate: session rate is above the configured ceiling — stepping down"
|
||||
);
|
||||
self.ceiling_ask_kbps = ceiling_target;
|
||||
return self.request(ceiling_target, now);
|
||||
}
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
@@ -602,6 +746,17 @@ impl BitrateController {
|
||||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||||
Some(kbps)
|
||||
}
|
||||
|
||||
/// The decision [`on_window`](Self::on_window) returned never reached the wire (the control
|
||||
/// queue was full). Undo the request's bookkeeping: [`MAX_UNACKED`] exists to detect a HOST
|
||||
/// that doesn't answer, and counting a message we never sent toward it retires the
|
||||
/// controller for the session — with a log line blaming an "older host" that is not what
|
||||
/// happened. Clearing the pending request also keeps a later unsolicited ack from being
|
||||
/// judged short against a rate we never asked for.
|
||||
pub(crate) fn on_request_dropped(&mut self) {
|
||||
self.unacked = self.unacked.saturating_sub(1);
|
||||
self.last_requested_kbps = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1110,14 +1265,16 @@ mod tests {
|
||||
|
||||
#[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.
|
||||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates below it.
|
||||
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),
|
||||
// Slow start doubles while the decoder keeps up, and the first BASELINE_MIN_WINDOWS of
|
||||
// those windows are what teach the decode baseline (one sample is not a floor).
|
||||
let mut last = 0;
|
||||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i * 2),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1125,16 +1282,18 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
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…
|
||||
0,
|
||||
) {
|
||||
last = k;
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(last, 300_000, "slow start should reach the probed ceiling");
|
||||
// Now the decoder starts backing up (30 ms over the learned baseline): the window is bad,
|
||||
// so the climb stops instead of parking at the link ceiling…
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 2),
|
||||
ticks(start, 20),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1146,11 +1305,11 @@ mod tests {
|
||||
),
|
||||
None
|
||||
);
|
||||
// …and a second backed-up window backs the rate off, settling at the decode limit rather
|
||||
// …and a second backed-up window backs the rate off toward the real decode limit rather
|
||||
// than choking the decoder at the link ceiling (the reported bug).
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
ticks(start, 22),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1160,7 +1319,54 @@ mod tests {
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(28_000)
|
||||
Some(210_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_calm_window_is_not_a_baseline() {
|
||||
// The ratchet this guard exists to stop: our own decrease CLEARS the encode baseline, so
|
||||
// it re-seeds from whatever the next window happens to be. If that window is calm, the
|
||||
// ordinary content variance that follows reads as a rise, backs off, clears again — all
|
||||
// the way to the floor on a link that was never the problem. A single sample must not
|
||||
// arm the signal.
|
||||
let mut c = BitrateController::new(100_000);
|
||||
let start = Instant::now();
|
||||
// One calm 3 ms encode window, then windows 9 ms above it: far past ENCODE_RISE_US, and
|
||||
// sustained — yet no baseline exists to judge them against yet.
|
||||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||||
let mean = if i == 0 { 3_000 } else { 12_000 };
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(mean),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None,
|
||||
"window {i} fired off a baseline of fewer than {BASELINE_MIN_WINDOWS} samples"
|
||||
);
|
||||
}
|
||||
// With a real baseline (min 3 ms over 4 windows) the signal works exactly as before: a
|
||||
// sustained rise past it still backs the rate off.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 8),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(70_000)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1385,8 +1591,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
|
||||
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after a clean run parked at
|
||||
// the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
@@ -1396,7 +1602,10 @@ mod tests {
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
// The FIRST re-probe is the fast one — a transient refusal must not cost the session
|
||||
// minutes to escape.
|
||||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 20 + i),
|
||||
0,
|
||||
@@ -1412,6 +1621,130 @@ mod tests {
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_refusal_does_not_pin_the_session() {
|
||||
// The field failure this whole cap-escape change exists for. A host that escalates its
|
||||
// capture/encode pipeline once — a startup hitch is enough — used to refuse every climb
|
||||
// for the rest of the session; the client latched that refusal as a cap, at whatever
|
||||
// rate slow start had reached, which is routinely the 20 Mbps default. Escaping cost
|
||||
// +12.5 % per ~60 s: north of twenty minutes to reach a 300 Mbps link ceiling, which the
|
||||
// user experiences as "Automatic is broken".
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(300_000); // the startup probe measured a fat link
|
||||
let start = Instant::now();
|
||||
let mut tick = 0u32;
|
||||
let mut windows_pinned = 0u32;
|
||||
// Two refused climbs at the same rate → the cap latches at 20 Mbps.
|
||||
for _ in 0..2 {
|
||||
let k = run_clean(&mut c, start, tick, 4).expect("slow start should ask to climb");
|
||||
tick += 4;
|
||||
assert!(k > 20_000);
|
||||
c.on_ack(20_000); // "behind cadence — held at the current rate"
|
||||
}
|
||||
assert_eq!(c.host_cap_kbps, Some(20_000));
|
||||
// The host recovers immediately (its bucket drains; the escalation bought the headroom
|
||||
// it was for), but the client has no way to know that except by asking again. Drive
|
||||
// clean windows and grant whatever it asks for.
|
||||
while c.current_kbps < 150_000 && windows_pinned < 400 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
tick += 1;
|
||||
windows_pinned += 1;
|
||||
}
|
||||
assert!(
|
||||
c.current_kbps >= 150_000,
|
||||
"still pinned at {} after {windows_pinned} windows",
|
||||
c.current_kbps
|
||||
);
|
||||
// ~750 ms a window: this must be tens of seconds, not the old tens of minutes.
|
||||
assert!(
|
||||
windows_pinned <= 40,
|
||||
"took {windows_pinned} windows (~{} s) to escape a transient refusal",
|
||||
windows_pinned * 3 / 4
|
||||
);
|
||||
// And the disproven cap is gone, not merely nudged upward.
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_retarget_above_the_ceiling_raises_it() {
|
||||
// The host sends an unsolicited `BitrateChanged` when a rebuild re-resolves an Automatic
|
||||
// rate for what it ACTUALLY encodes — a 1080p session mirroring a 4K panel resolves far
|
||||
// above the negotiated rate. That is the host's own Automatic answer, so the climb
|
||||
// ceiling has to follow it; otherwise the ceiling stays stale and the step-down drags
|
||||
// the host straight back off the rate it just chose.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(c.ceiling_kbps, 20_000);
|
||||
c.on_ack(60_000); // unsolicited: no request was outstanding
|
||||
assert_eq!(c.current_kbps, 60_000);
|
||||
assert_eq!(c.ceiling_kbps, 60_000);
|
||||
let start = Instant::now();
|
||||
// No step-down, and no spurious re-target of any kind.
|
||||
assert_eq!(run_clean(&mut c, start, 0, 4), None);
|
||||
// The operator's cap still outranks it — that is the one thing that must bind here.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.on_ack(60_000);
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standing_cap_backs_its_reprobe_clock_off() {
|
||||
// The other half of the re-probe: an encoder's real codec ceiling (794 Mbps, L6.2)
|
||||
// re-teaches itself every time the lift is tried. Escaping fast is right for a
|
||||
// transient and pointless here, so each re-learn doubles the interval — a hard limit
|
||||
// settles into a slow poll instead of two acks every 12 s for the whole session.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
// Each round: park clean at the cap until it re-probes upward, then have the host refuse
|
||||
// the lift at the same value again. That is a STANDING limit, so the clock doubles.
|
||||
let mut tick = 20;
|
||||
for round in 0..3 {
|
||||
let before = c.cap_reprobe_after;
|
||||
for _ in 0..before {
|
||||
let _ = c.on_window(
|
||||
ticks(start, tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
tick += 1;
|
||||
}
|
||||
let lifted = c.host_cap_kbps.expect("cap should still be latched");
|
||||
assert!(lifted > 794_000, "round {round}: the re-probe never lifted");
|
||||
// The host clamps the lift straight back to its real ceiling.
|
||||
c.last_requested_kbps = Some(lifted);
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
assert_eq!(
|
||||
c.cap_reprobe_after,
|
||||
(before * 2).min(CAP_REPROBE_WINDOWS_MAX)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_encode_latency_rise_backs_off() {
|
||||
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||||
@@ -1593,6 +1926,25 @@ mod tests {
|
||||
assert_eq!(run_clean(&mut c, start, 4, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_above_the_env_cap_steps_down_to_it_once() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS is the only lever an Automatic session gives the operator, and
|
||||
// it used to bind only ceilings the PROBE taught — so a session that negotiated a rate
|
||||
// above the cap simply ran above it forever. No congestion signal will ever find that:
|
||||
// the link is fine, the cap is policy.
|
||||
let mut c = BitrateController::with_ceiling_cap(100_000, Some(50_000));
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||||
// Suppose the host answers HIGHER than asked (its own floor, an encoder minimum): that
|
||||
// is the host saying it cannot go there. Don't re-ask every cooldown forever.
|
||||
c.on_ack(80_000);
|
||||
assert_eq!(run_clean(&mut c, start, 2, 20), None);
|
||||
// A ceiling that MOVES is a new question, and gets asked once more.
|
||||
c.set_ceiling(90_000); // clamped to the 50 Mbps cap → still 50 000, no new ask
|
||||
assert_eq!(run_clean(&mut c, start, 24, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
@@ -1650,7 +2002,7 @@ mod tests {
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
// link ceiling the old sawtooth kept re-poking.
|
||||
c.on_ack(350_000);
|
||||
@@ -1667,14 +2019,22 @@ mod tests {
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
assert!(k <= 500_000, "climb past the decode cap: {k}");
|
||||
// Never past the cap in force when the decision was made. (A long clean run
|
||||
// legitimately re-probes that cap upward — `decode_cap_reprobes_after_a_
|
||||
// sustained_clean_run` owns that; here the point is that nothing climbs toward
|
||||
// the 900 Mbps LINK ceiling the old sawtooth kept re-poking.)
|
||||
assert!(
|
||||
k <= c.decode_cap_kbps.unwrap(),
|
||||
"climb past the decode cap: {k}"
|
||||
);
|
||||
max_req = max_req.max(k);
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(max_req, 500_000);
|
||||
assert_eq!(c.current_kbps, 500_000);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert!(
|
||||
max_req < 600_000,
|
||||
"the decode knee stopped binding: climbed to {max_req}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1748,10 +2108,10 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The host's ack parks the session at the knee (its clamp is authoritative).
|
||||
c.on_ack(500_000);
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
c.on_ack(500_000 - 500_000 / 16);
|
||||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 8 + i),
|
||||
0,
|
||||
@@ -1764,7 +2124,8 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 + 500_000 / 8));
|
||||
let knee = 500_000 - 500_000 / 16;
|
||||
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1800,7 +2161,7 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
c.on_mode_switch();
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 900_000);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -490,7 +490,13 @@ impl NativeClient {
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
display_hdr,
|
||||
client_caps,
|
||||
// Redundant audio (`0xD2`) is advertised by CORE, not by the embedder: the
|
||||
// recovery happens on the demux side (`AudioRedRecovery` in the datagram
|
||||
// task) and re-inserts the rebuilt frame into the same queue, so every
|
||||
// embedder benefits without knowing the plane exists — and none of them can
|
||||
// forget to opt in. The bit is a pure "I can decode it"; the host still
|
||||
// decides whether to spend the extra ~1 %.
|
||||
client_caps: client_caps | crate::quic::CLIENT_CAP_AUDIO_RED,
|
||||
frame_parts,
|
||||
launch,
|
||||
name,
|
||||
|
||||
@@ -232,7 +232,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_bytes = st.media_bytes_received;
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
@@ -317,11 +317,12 @@ impl DataPump {
|
||||
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
|
||||
// the decoder — rebase the ABR window's byte counter past it, or the next
|
||||
// window's "actual throughput" reads as the burst rate and poisons the
|
||||
// controller's proven-throughput high-water mark with the LINK rate.
|
||||
last_bytes = st.bytes_received;
|
||||
// Rebase the ABR window's byte anchor past the burst. (Probe filler is
|
||||
// routed out of `media_bytes_received` at the reassembler, so it can no
|
||||
// longer read as the burst rate on its own — but the anchor still has to
|
||||
// skip the video that landed around the burst under a suppressed report
|
||||
// tick, which would otherwise divide a long span's bytes by one window.)
|
||||
last_bytes = st.media_bytes_received;
|
||||
} else if Instant::now() >= deadline {
|
||||
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||
@@ -454,11 +455,17 @@ impl DataPump {
|
||||
// the next one.
|
||||
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
// semantics (both compare against targets with their own headroom).
|
||||
// the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC
|
||||
// parity, no probe filler, no audio), because both consumers compare it against
|
||||
// the ENCODER's target: the utilization gate asks "was the target genuinely
|
||||
// tested?" and the proven mark bounds every later climb. Wire bytes answered a
|
||||
// different question — they rise with the redundancy the host adds in answer to
|
||||
// loss, so the gate read ~25 % high precisely on the links it exists for.
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||
let actual_kbps = (st
|
||||
.media_bytes_received
|
||||
.wrapping_sub(last_bytes)
|
||||
.saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
// A discard window feeds the controller NOTHING — its signals are probe-tail
|
||||
// residue, and one "congestion" verdict here ends slow start for good.
|
||||
@@ -492,7 +499,15 @@ impl DataPump {
|
||||
recovery_kf = recovery_kf_reqs,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||
if ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)).is_err() {
|
||||
// Never reached the control task — tell the controller, or three of
|
||||
// these retire it for the session as "the host never acked".
|
||||
abr.on_request_dropped();
|
||||
tracing::warn!(
|
||||
kbps,
|
||||
"adaptive bitrate: control queue full — re-target dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
flush_in_window = false;
|
||||
last_report = Instant::now();
|
||||
@@ -500,7 +515,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_bytes = st.media_bytes_received;
|
||||
if pump_perf_on {
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
|
||||
@@ -23,6 +23,10 @@ pub(super) async fn run(
|
||||
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
|
||||
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
|
||||
// Redundant-audio-plane rebuild (`0xD2`). Recovery happens HERE rather than in the four
|
||||
// client decoders: the recovered frame is re-inserted into this queue in order, so every
|
||||
// embedder gets a complete stream without knowing the plane exists.
|
||||
let mut audio_red = crate::audio::AudioRedRecovery::new();
|
||||
while let Ok(d) = conn.read_datagram().await {
|
||||
match d.first() {
|
||||
Some(&crate::quic::AUDIO_MAGIC) => {
|
||||
@@ -34,6 +38,26 @@ pub(super) async fn run(
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::AUDIO_RED_MAGIC) => {
|
||||
if let Some((seq, pts_ns, opus, prev)) = crate::quic::decode_audio_red_datagram(&d)
|
||||
{
|
||||
if audio_red.recover_before(seq, prev.is_some()) {
|
||||
// The copy is the frame BEFORE this one, so it carries the previous
|
||||
// sequence and presentation time — one protocol frame earlier.
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq: seq.wrapping_sub(1),
|
||||
pts_ns: pts_ns
|
||||
.saturating_sub(crate::audio::FRAME_MS as u64 * 1_000_000),
|
||||
data: prev.unwrap_or_default().to_vec(),
|
||||
});
|
||||
}
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq,
|
||||
pts_ns,
|
||||
data: opus.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
|
||||
@@ -341,6 +341,50 @@ pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
|
||||
/// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
|
||||
/// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
|
||||
/// bottom out here instead of producing degenerate confetti-sized shards.
|
||||
pub const MIN_SHARD_PAYLOAD: usize = 512;
|
||||
|
||||
/// The sealed wire size of a video datagram carrying `shard_payload` bytes of shard — what
|
||||
/// actually leaves the socket as UDP payload (punktfunk header + shard + crypto overhead).
|
||||
pub const fn sealed_datagram_bytes(shard_payload: usize) -> usize {
|
||||
HEADER_LEN + shard_payload + CRYPTO_OVERHEAD
|
||||
}
|
||||
|
||||
/// The UDP-payload size a path must carry for full-size IPv4 video datagrams: the sealed size
|
||||
/// of the [`mtu1500_shard_payload`] default (= 1472, the exact 1500-MTU IPv4 ceiling). Doubles
|
||||
/// as the QUIC MTU-discovery probe ceiling (`quic/endpoint.rs`): with the ceiling set to
|
||||
/// exactly this value, a control connection whose discovery settles AT the ceiling has proven
|
||||
/// the path carries full-size video datagrams, and one that settles BELOW it has proven the
|
||||
/// path cannot — a discrimination quinn's stock 1452 ceiling can't make in either direction.
|
||||
pub const fn video_datagram_udp_ceiling() -> usize {
|
||||
sealed_datagram_bytes(mtu1500_shard_payload())
|
||||
}
|
||||
|
||||
/// Largest even shard payload whose sealed datagram fits in `udp_budget` bytes of UDP payload
|
||||
/// (the quantity QUIC MTU discovery measures — [`video_datagram_udp_ceiling`] is its probe
|
||||
/// ceiling). Clamped to the peer's family default ([`mtu1500_shard_payload_for`]) so a generous
|
||||
/// budget never grows packets past today's wire, and floored at [`MIN_SHARD_PAYLOAD`].
|
||||
pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) -> usize {
|
||||
let p = udp_budget.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
|
||||
let p = p - p % 2; // FEC requires even shards
|
||||
p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer))
|
||||
}
|
||||
|
||||
/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number
|
||||
/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP
|
||||
/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
|
||||
pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
|
||||
let ip_udp = match peer {
|
||||
core::net::IpAddr::V4(_) => 28,
|
||||
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
|
||||
core::net::IpAddr::V6(_) => 48,
|
||||
};
|
||||
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp), peer)
|
||||
}
|
||||
|
||||
/// Everything needed to construct a [`Session`](crate::session::Session).
|
||||
///
|
||||
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
|
||||
@@ -514,6 +558,74 @@ mod tests {
|
||||
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal");
|
||||
}
|
||||
|
||||
/// The video-datagram ceiling IS the exact v4 sealed size — the QUIC MTU-discovery probe
|
||||
/// ceiling (endpoint.rs) relies on this equality for its settled-at-vs-below verdict.
|
||||
#[test]
|
||||
fn video_datagram_ceiling_is_the_sealed_default() {
|
||||
assert_eq!(
|
||||
video_datagram_udp_ceiling(),
|
||||
HEADER_LEN + mtu1500_shard_payload() + CRYPTO_OVERHEAD
|
||||
);
|
||||
assert_eq!(video_datagram_udp_ceiling(), 1472);
|
||||
}
|
||||
|
||||
/// Budget-derived sizing: even, sealed-fits-the-budget, clamped to the family default
|
||||
/// above and [`MIN_SHARD_PAYLOAD`] below.
|
||||
#[test]
|
||||
fn shard_payload_for_udp_budget_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
// The full ceiling reproduces the default exactly.
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(video_datagram_udp_ceiling(), v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
// A WARP/Tailscale-shaped 1280 budget: sealed result must fit the budget, stay even.
|
||||
let p = shard_payload_for_udp_budget(1280, v4);
|
||||
assert_eq!(p % 2, 0);
|
||||
assert!(sealed_datagram_bytes(p) <= 1280);
|
||||
assert!(sealed_datagram_bytes(p + 2) > 1280, "not maximal");
|
||||
// Odd budgets round down to even shards.
|
||||
assert_eq!(shard_payload_for_udp_budget(1281, v4) % 2, 0);
|
||||
// A generous budget never grows past the family default (either family).
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(9000, v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(9000, v6),
|
||||
mtu1500_shard_payload_v6()
|
||||
);
|
||||
// Degenerate budgets bottom out at the floor instead of confetti.
|
||||
assert_eq!(shard_payload_for_udp_budget(100, v4), MIN_SHARD_PAYLOAD);
|
||||
}
|
||||
|
||||
/// Operator-facing wire-MTU sizing subtracts the right IP+UDP header per family, and 1500
|
||||
/// reproduces today's defaults exactly.
|
||||
#[test]
|
||||
fn shard_payload_for_wire_mtu_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap();
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, mapped),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, v6),
|
||||
mtu1500_shard_payload_v6()
|
||||
);
|
||||
// 1280 wire − 28 − 64 = 1188 (v4); − 48 − 64 = 1168 (v6).
|
||||
assert_eq!(shard_payload_for_wire_mtu(1280, v4), 1188);
|
||||
assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168);
|
||||
}
|
||||
|
||||
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
|
||||
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
|
||||
#[test]
|
||||
|
||||
@@ -73,9 +73,9 @@ pub struct StreamedAu {
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
|
||||
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
|
||||
/// whatever remains).
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte — flushes emit only whole
|
||||
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
|
||||
/// so `finish_streamed` always has something real to seal.
|
||||
pending: Vec<u8>,
|
||||
/// Sentinel blocks already emitted.
|
||||
blocks_out: u16,
|
||||
@@ -418,7 +418,18 @@ impl Packetizer {
|
||||
"streamed AU exceeds the negotiated max_frame_bytes",
|
||||
));
|
||||
}
|
||||
let k = whole.min(self.fec.max_data_per_block as usize);
|
||||
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
|
||||
// or the final block degenerates to a single zero-padded filler shard whose derived
|
||||
// base (`total_data − 1`) overlaps the block flushed just now — which the receiver's
|
||||
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
|
||||
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
|
||||
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
|
||||
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
|
||||
// it rides out in the final block, which has to exist regardless.
|
||||
let mut k = whole.min(self.fec.max_data_per_block as usize);
|
||||
if k > 1 && k == whole && au.pending.len() == whole * payload {
|
||||
k -= 1;
|
||||
}
|
||||
let sof = !au.opened;
|
||||
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
|
||||
let fi = au.frame_index;
|
||||
|
||||
@@ -429,6 +429,13 @@ impl Reassembler {
|
||||
stats
|
||||
.probe_last_arrival_ns
|
||||
.store(now_ns, std::sync::atomic::Ordering::Relaxed);
|
||||
} else if hdr.shard_index < hdr.data_shards {
|
||||
// Media accounting (see `Stats::media_bytes_received`): DATA shards only, payload
|
||||
// only. Stamped at the same routing decision as the probe counters and for the same
|
||||
// reason — the adaptive-bitrate utilization gate compares delivered throughput
|
||||
// against an ENCODER target, so parity, headers and probe filler have no business
|
||||
// in the numerator.
|
||||
StatsCounters::add(&stats.media_bytes_received, shard_bytes as u64);
|
||||
}
|
||||
let win = if is_probe { probe } else { video };
|
||||
win.advance_window(
|
||||
@@ -467,14 +474,33 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
|
||||
// the limits' maximum — its real size doesn't exist yet.
|
||||
let buf_len = if sentinel {
|
||||
total_data_max * shard_bytes
|
||||
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
|
||||
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
|
||||
// a legacy one by its full-K position — and that is what the buffer must cover to place
|
||||
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
|
||||
// block's totals settle it.
|
||||
//
|
||||
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
|
||||
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
|
||||
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
|
||||
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
|
||||
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
|
||||
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
|
||||
// exhausted after ~3 concurrent frames — beyond which every packet of every further
|
||||
// frame was dropped outright. On a jittery link that is a permanent loss storm.
|
||||
let need_shards = if sentinel && slice_stream {
|
||||
frame_bytes / shard_bytes + data_shards
|
||||
} else if sentinel {
|
||||
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
|
||||
// index alone gives its end.
|
||||
(block_idx + 1).saturating_mul(lim.max_data_shards)
|
||||
} else {
|
||||
total_data * shard_bytes
|
||||
};
|
||||
total_data
|
||||
}
|
||||
.min(total_data_max);
|
||||
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
|
||||
// agree with its geometry.
|
||||
let buf_len = need_shards * shard_bytes;
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
@@ -602,6 +628,21 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
|
||||
// first and learns its real size from the final block's totals (or a later, higher
|
||||
// sentinel base) — reorder means either can come first, so the buffer is sized by
|
||||
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
|
||||
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
|
||||
// for exactly the reason it is checked at open — growth commits memory too.
|
||||
if buf_len > frame.buf.len() {
|
||||
let delta = buf_len - frame.buf.len();
|
||||
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += delta;
|
||||
frame.buf.resize(buf_len, 0);
|
||||
}
|
||||
let FrameBuf {
|
||||
buf,
|
||||
blocks,
|
||||
|
||||
@@ -941,8 +941,9 @@ fn slice_config() -> Config {
|
||||
|
||||
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
|
||||
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
|
||||
/// final (1, 63) with block_count 4.
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
|
||||
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
|
||||
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
|
||||
fn slice_chunks() -> Vec<Vec<u8>> {
|
||||
[320usize, 403, 100, 200]
|
||||
.iter()
|
||||
@@ -1007,7 +1008,8 @@ fn slice_streamed_wire_shape_and_roundtrip() {
|
||||
assert_eq!(src.len(), 1023);
|
||||
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
|
||||
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
|
||||
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
|
||||
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
|
||||
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_ne!(
|
||||
@@ -1478,15 +1480,24 @@ fn parts_flow_for_legacy_streamed_frames() {
|
||||
assert!(got.last().unwrap().complete);
|
||||
}
|
||||
|
||||
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
|
||||
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
|
||||
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
|
||||
/// in-flight budget still bounds the ones that claim a lot.
|
||||
///
|
||||
/// Both halves matter. A sentinel that claims little must cost little: sizing every
|
||||
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
|
||||
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
|
||||
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
|
||||
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
|
||||
/// amplification this budget exists for.
|
||||
#[test]
|
||||
fn streamed_open_amplification_is_budget_bounded() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
|
||||
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
|
||||
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
|
||||
for fi in 0..5u32 {
|
||||
for fi in 0..32u32 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
@@ -1498,10 +1509,35 @@ fn streamed_open_amplification_is_budget_bounded() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
0,
|
||||
"ordinary one-datagram opens must not exhaust the in-flight budget"
|
||||
);
|
||||
|
||||
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
|
||||
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
|
||||
// fifth must be refused.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
for fi in 0..5u32 {
|
||||
let mut h = base_header();
|
||||
h.user_flags = USER_FLAG_SLICE_STREAM;
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 4096 - 8 * 16;
|
||||
h.block_index = 1;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.frame_index = fi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the fifth max-sized open must be refused by the in-flight budget"
|
||||
"the fifth ceiling-claiming open must be refused by the in-flight budget"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1612,3 +1648,124 @@ fn streamed_second_final_with_different_totals_is_rejected() {
|
||||
.expect("frame completes under the first pinned totals");
|
||||
assert_eq!(got.data.len(), 160);
|
||||
}
|
||||
|
||||
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
|
||||
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
|
||||
fn prod_slice_config() -> Config {
|
||||
use crate::config::{FecConfig, ProtocolPhase, Role};
|
||||
Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 20,
|
||||
max_data_per_block: 200,
|
||||
},
|
||||
shard_payload: crate::config::mtu1500_shard_payload(),
|
||||
max_frame_bytes: 8 << 20,
|
||||
encrypt: false,
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
|
||||
fn streamed_packets_with(
|
||||
cfg: &Config,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
slice: bool,
|
||||
chunks: &[usize],
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let coder = coder_for(cfg.fec.scheme);
|
||||
let mut pk = Packetizer::new(cfg);
|
||||
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
|
||||
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
|
||||
let (mut pkts, mut src) = (Vec::new(), Vec::new());
|
||||
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
};
|
||||
for (c, &n) in chunks.iter().enumerate() {
|
||||
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
|
||||
src.extend_from_slice(&data);
|
||||
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
pk.finish_streamed(au, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
|
||||
///
|
||||
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
|
||||
/// block of one zero-padded FILLER shard. Its derived base (`total_data − 1`) overlapped the
|
||||
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
|
||||
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
|
||||
/// each costing a re-anchor freeze and a recovery keyframe.
|
||||
#[test]
|
||||
fn slice_streamed_exact_shard_multiple_completes() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let payload = cfg.shard_payload;
|
||||
for shards in [16usize, 29, 30, 64] {
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
|
||||
// Whatever the block split, the final block must carry real bytes — never a lone
|
||||
// zero-pad shard sitting on top of the previous block's range.
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
|
||||
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
|
||||
}
|
||||
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
|
||||
for extra in 0..3usize {
|
||||
let n = 30 * payload + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
|
||||
///
|
||||
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
|
||||
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
|
||||
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
|
||||
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
|
||||
/// normal reorder, plus a multi-megabyte zeroing per access unit.
|
||||
#[test]
|
||||
fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
|
||||
// holds several of at once.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,105 @@
|
||||
//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md):
|
||||
//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so
|
||||
//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host
|
||||
//! the client-side half of the controller's v2 error signal, plus the panel-grid learner every
|
||||
//! vsync-aware presenter paces against. Pure math, no features — shared so every presenter
|
||||
//! (Android today, iOS and the desktop session client next) computes the SAME statistic the host
|
||||
//! controller was tuned against, and so the controller's simulation tests can generate their
|
||||
//! synthetic reports through the identical code path.
|
||||
|
||||
/// Plausible panel periods: ~24 Hz to ~500 Hz. A spacing outside this is a clock glitch, not a
|
||||
/// display mode, and must never reach the estimate.
|
||||
const PANEL_PERIOD_RANGE_NS: std::ops::RangeInclusive<i64> = 2_000_000..=42_000_000;
|
||||
|
||||
/// Spacings within this of the estimate are the same grid — absorbs ordinary timeline jitter.
|
||||
const PANEL_GRID_TOLERANCE_NS: i64 = 200_000;
|
||||
|
||||
/// Consecutive WIDER observations required before the estimate grows. One stray wide sample is a
|
||||
/// scheduling hiccup; eight in a row (~66 ms at 120 Hz) is a display that really did slow down.
|
||||
const PANEL_WIDEN_STREAK: u8 = 8;
|
||||
|
||||
/// The panel's true refresh period, learned from observed vsync/frame-timeline spacing.
|
||||
///
|
||||
/// A presenter subdivides its release targets onto this grid, so an estimate FINER than the panel
|
||||
/// makes it aim at instants that never arrive and release faster than the display consumes —
|
||||
/// which is why the estimate has to be able to move both ways.
|
||||
///
|
||||
/// Seeding is the reason this is not simply "believe the last sample". The platform's *configured*
|
||||
/// mode is not the panel: under a per-uid frame-rate override a 120 Hz panel reports 60
|
||||
/// (`Display.getRefreshRate` returns the override — observed on-glass, A024), and the app's own
|
||||
/// choreographer callbacks arrive at the down-rated rate while the panel scans at its own. The
|
||||
/// mode TABLE is honest about what the panel *can* do, so it is the seed; the timeline spacing is
|
||||
/// honest about what it is *doing*, so it is the correction.
|
||||
///
|
||||
/// The asymmetry is deliberate. **Narrowing is immediate**: a finer real grid is always safe to
|
||||
/// subdivide onto, and it is the down-rate case the seed most often gets wrong. **Widening needs
|
||||
/// [`PANEL_WIDEN_STREAK`] consecutive agreeing observations** and then adopts the *narrowest* of
|
||||
/// them, because a wide sample is far more likely to be a missed callback than a mode change.
|
||||
///
|
||||
/// ⚠ 0.23.0 shipped this learner as narrow-only, seeded from the display mode the app *requests*
|
||||
/// (`preferredDisplayModeId` is a hint the system may refuse). A refused 120 Hz switch therefore
|
||||
/// left the presenter pacing a 60 Hz panel on an 8.33 ms grid with no way back — permanently.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PanelGrid {
|
||||
period_ns: i64,
|
||||
widen_streak: u8,
|
||||
/// Narrowest wider-than-estimate spacing seen during the current streak.
|
||||
widen_candidate: i64,
|
||||
}
|
||||
|
||||
impl PanelGrid {
|
||||
/// Seed from the display mode's refresh rate (`0` = unknown — the first plausible observation
|
||||
/// then sets the estimate outright).
|
||||
pub fn seeded(hz: i32) -> PanelGrid {
|
||||
PanelGrid {
|
||||
period_ns: if hz > 0 { 1_000_000_000 / hz as i64 } else { 0 },
|
||||
widen_streak: 0,
|
||||
widen_candidate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The learned period, or `0` while unknown.
|
||||
pub fn period_ns(&self) -> i64 {
|
||||
self.period_ns
|
||||
}
|
||||
|
||||
/// Fold one observed grid spacing. Returns `true` when [`period_ns`](Self::period_ns) changed.
|
||||
pub fn observe(&mut self, spacing_ns: i64) -> bool {
|
||||
if !PANEL_PERIOD_RANGE_NS.contains(&spacing_ns) {
|
||||
return false; // implausible — a clock glitch, not a display mode
|
||||
}
|
||||
if self.period_ns == 0 {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns < self.period_ns - PANEL_GRID_TOLERANCE_NS {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns > self.period_ns + PANEL_GRID_TOLERANCE_NS {
|
||||
self.widen_streak = self.widen_streak.saturating_add(1);
|
||||
self.widen_candidate = if self.widen_candidate == 0 {
|
||||
spacing_ns
|
||||
} else {
|
||||
self.widen_candidate.min(spacing_ns)
|
||||
};
|
||||
if self.widen_streak >= PANEL_WIDEN_STREAK {
|
||||
self.period_ns = self.widen_candidate;
|
||||
self.reset_streak();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.reset_streak(); // this sample agreed — the run of wider ones is broken
|
||||
false
|
||||
}
|
||||
|
||||
fn reset_streak(&mut self) {
|
||||
self.widen_streak = 0;
|
||||
self.widen_candidate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch
|
||||
/// mod the period (ns) and the coherence (‰).
|
||||
///
|
||||
@@ -90,3 +186,111 @@ mod tests {
|
||||
assert!(circular_latch(&[1_000; 16], 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_grid_tests {
|
||||
use super::*;
|
||||
|
||||
const P120: i64 = 8_333_333;
|
||||
const P60: i64 = 16_666_666;
|
||||
|
||||
#[test]
|
||||
fn seeds_from_the_mode_and_reports_unknown_without_one() {
|
||||
assert_eq!(PanelGrid::seeded(120).period_ns(), 8_333_333);
|
||||
assert_eq!(PanelGrid::seeded(0).period_ns(), 0);
|
||||
let mut g = PanelGrid::seeded(0);
|
||||
assert!(
|
||||
g.observe(P120),
|
||||
"the first plausible sample sets an unseeded grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrows_immediately_when_the_panel_is_faster_than_the_mode_said() {
|
||||
// The down-rate case: the mode table read 60, the timelines run at 120.
|
||||
let mut g = PanelGrid::seeded(60);
|
||||
assert!(g.observe(P120));
|
||||
assert_eq!(g.period_ns(), P120, "a finer real grid is adopted at once");
|
||||
}
|
||||
|
||||
/// The 0.23.0 bug: `preferredDisplayModeId` is a request, so a refused 120 Hz switch seeds a
|
||||
/// 120 Hz grid on a panel that is really running 60. The narrow-only learner could never
|
||||
/// climb back, and the presenter aimed at instants the panel never reached.
|
||||
#[test]
|
||||
fn widens_back_out_when_the_requested_mode_was_refused() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for i in 0..PANEL_WIDEN_STREAK - 1 {
|
||||
assert!(!g.observe(P60), "sample {i} must not widen on its own");
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
assert!(
|
||||
g.observe(P60),
|
||||
"a sustained run of wider spacings widens the grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_wide_sample_never_widens() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..40 {
|
||||
assert!(!g.observe(P60));
|
||||
assert!(!g.observe(P120)); // an agreeing sample breaks the run
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P120,
|
||||
"alternating samples must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_adopts_the_narrowest_of_the_run() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
// A run of wide spacings that includes some very wide outliers.
|
||||
let run = [
|
||||
P60,
|
||||
33_000_000,
|
||||
P60 + 400_000,
|
||||
41_000_000,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
];
|
||||
for s in run {
|
||||
g.observe(s);
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P60,
|
||||
"the estimate takes the narrowest of the run, never an outlier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_spacings_are_ignored_entirely() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..100 {
|
||||
assert!(!g.observe(0));
|
||||
assert!(!g.observe(-1));
|
||||
assert!(!g.observe(1_000_000)); // 1000 Hz — below the range floor
|
||||
assert!(!g.observe(100_000_000)); // 10 Hz — above the ceiling
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_narrow_glitch_self_heals() {
|
||||
// Narrowing is immediate, so a glitch DOES poison the estimate — the point is that it is
|
||||
// no longer permanent (0.23.0's learner had no way back).
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
assert!(g.observe(2_100_000), "a glitch narrows the estimate");
|
||||
assert_eq!(g.period_ns(), 2_100_000);
|
||||
for _ in 0..PANEL_WIDEN_STREAK {
|
||||
g.observe(P120);
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,17 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// simply ignored — no behavior change in either direction.
|
||||
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
|
||||
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
|
||||
/// carries a copy of the previous frame so a single lost packet is reconstructed instead of
|
||||
/// papered over with packet-loss concealment.
|
||||
///
|
||||
/// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
|
||||
/// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
|
||||
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
||||
@@ -132,6 +143,18 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
|
||||
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
|
||||
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
|
||||
/// datagram carries its own frame plus a copy of the previous one.
|
||||
///
|
||||
/// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
|
||||
/// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
|
||||
/// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
|
||||
/// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
|
||||
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
|
||||
@@ -42,6 +42,80 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
Some((seq, pts_ns, &b[13..]))
|
||||
}
|
||||
|
||||
/// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
|
||||
/// frame, so a single lost datagram is *reconstructed* rather than concealed.
|
||||
///
|
||||
/// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
|
||||
///
|
||||
/// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
|
||||
/// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
|
||||
/// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
|
||||
/// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
|
||||
/// mode at 10 ms, and *does* use real in-band FEC.)
|
||||
///
|
||||
/// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
|
||||
/// client is already holding 15–90 ms of de-jitter buffer — far more than the 5 ms the successor
|
||||
/// takes to arrive. So the recovery happens inside slack that already exists.
|
||||
///
|
||||
/// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
|
||||
/// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
|
||||
/// which decodes to `None`.
|
||||
///
|
||||
/// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
|
||||
/// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
|
||||
/// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
|
||||
/// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
|
||||
///
|
||||
/// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
|
||||
/// per-pad audio plane.
|
||||
pub const AUDIO_RED_MAGIC: u8 = 0xD2;
|
||||
|
||||
/// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
|
||||
pub const AUDIO_RED_HEADER: usize = 1 + 4 + 8 + 2;
|
||||
|
||||
/// Encode a redundant audio datagram. `prev` is the immediately-preceding frame's Opus payload
|
||||
/// (empty when there is none yet).
|
||||
pub fn encode_audio_red_datagram(seq: u32, pts_ns: u64, opus: &[u8], prev: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(AUDIO_RED_HEADER + opus.len() + prev.len());
|
||||
b.push(AUDIO_RED_MAGIC);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
// A frame longer than u16::MAX cannot occur (5 ms of Opus is tens of bytes; the buffer the
|
||||
// encoder writes into is 4 KiB) — but truncating silently would desync the split, so clamp
|
||||
// the redundancy off instead of the primary.
|
||||
let primary_len = u16::try_from(opus.len()).unwrap_or(u16::MAX);
|
||||
b.extend_from_slice(&primary_len.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
if opus.len() == primary_len as usize {
|
||||
b.extend_from_slice(prev);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a redundant audio datagram → `(seq, pts_ns, primary, previous)`. `previous` is `None`
|
||||
/// when the host had nothing to duplicate. `None` overall on bad tag/length, including a
|
||||
/// `primary_len` that overruns the datagram (a truncated or hostile packet must not panic).
|
||||
///
|
||||
/// The tuple shape deliberately mirrors [`decode_audio_datagram`] (one extra slot for the
|
||||
/// redundant copy) so the two planes read the same at every call site; a named struct here would
|
||||
/// be the odd one out on this module's decode surface, and cbindgen would then have to be taught
|
||||
/// to skip it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[u8]>)> {
|
||||
if b.len() < AUDIO_RED_HEADER || b[0] != AUDIO_RED_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
|
||||
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
|
||||
let primary_len = u16::from_le_bytes(b[13..15].try_into().unwrap()) as usize;
|
||||
let rest = &b[AUDIO_RED_HEADER..];
|
||||
if primary_len > rest.len() {
|
||||
return None; // truncated: the split point is outside the datagram
|
||||
}
|
||||
let (primary, prev) = rest.split_at(primary_len);
|
||||
Some((seq, pts_ns, primary, (!prev.is_empty()).then_some(prev)))
|
||||
}
|
||||
|
||||
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
|
||||
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
|
||||
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
|
||||
@@ -806,6 +880,8 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_datagram_roundtrip() {
|
||||
let opus = [0x42u8; 97];
|
||||
let d = encode_audio_red_datagram(7, 42, &opus, &[]);
|
||||
assert_eq!(d[0], AUDIO_RED_MAGIC);
|
||||
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
|
||||
assert_eq!(d[0], AUDIO_MAGIC);
|
||||
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
|
||||
@@ -820,6 +896,83 @@ mod tests {
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_red_datagram_roundtrip() {
|
||||
let cur = [0x42u8; 97];
|
||||
let prev = [0x37u8; 88];
|
||||
let d = encode_audio_red_datagram(7, 1_000_000_123, &cur, &prev);
|
||||
assert_eq!(d[0], AUDIO_RED_MAGIC);
|
||||
let (seq, pts, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||
assert_eq!(primary, cur);
|
||||
assert_eq!(previous, Some(&prev[..]));
|
||||
|
||||
// No predecessor yet (first frame of a session / after a capture reopen).
|
||||
let d = encode_audio_red_datagram(0, 5, &cur, &[]);
|
||||
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!(primary, cur);
|
||||
assert_eq!(
|
||||
previous, None,
|
||||
"an empty tail must decode as absent, not as a zero-length frame"
|
||||
);
|
||||
|
||||
// Frames of equal length must still split at the right place — the length prefix is the
|
||||
// only thing that can tell them apart.
|
||||
let a = [1u8; 64];
|
||||
let b = [2u8; 64];
|
||||
let d = encode_audio_red_datagram(9, 0, &a, &b);
|
||||
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!(primary, a);
|
||||
assert_eq!(previous, Some(&b[..]));
|
||||
}
|
||||
|
||||
/// A truncated or hostile `0xD2` must be rejected, never panic — the split point comes off
|
||||
/// the wire, so an over-long `primary_len` is the obvious attack on `split_at`.
|
||||
#[test]
|
||||
fn audio_red_datagram_rejects_bad_input() {
|
||||
let d = encode_audio_red_datagram(1, 2, &[0xAAu8; 30], &[0xBBu8; 20]);
|
||||
for n in 0..AUDIO_RED_HEADER {
|
||||
assert!(decode_audio_red_datagram(&d[..n]).is_none(), "len {n}");
|
||||
}
|
||||
// primary_len larger than the datagram: must be refused, not sliced.
|
||||
let mut bad = d.clone();
|
||||
bad[13..15].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert!(decode_audio_red_datagram(&bad).is_none());
|
||||
// Wrong tag.
|
||||
let mut wrong = d.clone();
|
||||
wrong[0] = AUDIO_MAGIC;
|
||||
assert!(decode_audio_red_datagram(&wrong).is_none());
|
||||
}
|
||||
|
||||
/// The two audio planes must not alias each other or any neighbouring plane: a client
|
||||
/// demultiplexes purely on the first byte.
|
||||
#[test]
|
||||
fn audio_red_tag_is_disjoint() {
|
||||
for other in [
|
||||
AUDIO_MAGIC,
|
||||
RUMBLE_MAGIC,
|
||||
MIC_MAGIC,
|
||||
RICH_INPUT_MAGIC,
|
||||
HIDOUT_MAGIC,
|
||||
HDR_META_MAGIC,
|
||||
HOST_TIMING_MAGIC,
|
||||
CURSOR_STATE_MAGIC,
|
||||
crate::input::INPUT_MAGIC,
|
||||
] {
|
||||
assert_ne!(AUDIO_RED_MAGIC, other);
|
||||
}
|
||||
let red = encode_audio_red_datagram(1, 2, &[9u8; 40], &[8u8; 40]);
|
||||
assert!(
|
||||
decode_audio_datagram(&red).is_none(),
|
||||
"0xC9 must not accept a 0xD2"
|
||||
);
|
||||
let plain = encode_audio_datagram(1, 2, &[9u8; 40]);
|
||||
assert!(
|
||||
decode_audio_red_datagram(&plain).is_none(),
|
||||
"0xD2 must not accept a 0xC9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_datagram_roundtrip() {
|
||||
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||
|
||||
@@ -47,6 +47,20 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
|
||||
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
|
||||
// surround bitrates), so sustained congestion costs concealable drops, never lag.
|
||||
t.datagram_send_buffer_size(4 * 1024);
|
||||
// MTU discovery probes up to EXACTLY the sealed size of a full IPv4 video datagram (1472)
|
||||
// instead of quinn's stock 1452. Two reasons: (a) on a clean 1500-MTU path QUIC gets the
|
||||
// last 20 bytes per packet; (b) the ceiling turns discovery into a video-path verdict the
|
||||
// host's wire-MTU watcher reads (`punktfunk-host` `native/wire_mtu.rs`) — settled == ceiling
|
||||
// proves the path carries full-size video datagrams, settled BELOW it proves it cannot (a
|
||||
// VPN/overlay adapter at MTU ~1280 blackholes every video packet while all the small flows
|
||||
// pass: the "connects fine, black screen forever" field shape). With the stock 1452 ceiling
|
||||
// a healthy path and a constrained one are indistinguishable at the top. This is the ONLY
|
||||
// behavioral change on healthy paths, and it's confined to discovery: probes are padded
|
||||
// PINGs quinn already expects to lose above a constrained hop — a lost probe settles the
|
||||
// search lower, exactly as it did before.
|
||||
let mut mtud = quinn::MtuDiscoveryConfig::default();
|
||||
mtud.upper_bound(crate::config::video_datagram_udp_ceiling() as u16);
|
||||
t.mtu_discovery_config(Some(mtud));
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,16 @@ pub struct Stats {
|
||||
/// so a speed-test numerator built from it inherits whatever video was in flight around
|
||||
/// the burst — these keep video out of the probe math. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats` (probe measurements surface via `ProbeOutcome`).
|
||||
/// Media bytes delivered to the video reassembler: DATA-shard payload only — no packet
|
||||
/// headers, no FEC parity, no probe filler, no audio. This is the rate the encoder's target
|
||||
/// is a promise about, and the only honest thing to compare that target against.
|
||||
/// `bytes_received` counts every accepted datagram, so a "delivered throughput" built from
|
||||
/// it rises with the FEC redundancy the host adds in answer to loss — which meant the
|
||||
/// adaptive-bitrate utilization gate ("did the pipeline actually carry ~the target?") read
|
||||
/// 25 % high exactly on the lossy links it exists for, and the never-decaying
|
||||
/// proven-throughput mark inherited the same inflation. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats`.
|
||||
pub media_bytes_received: u64,
|
||||
pub probe_packets_received: u64,
|
||||
pub probe_bytes_received: u64,
|
||||
/// First / last probe-packet arrival (monotonic ns, see [`now_monotonic_ns`]; 0 = none
|
||||
@@ -75,6 +85,7 @@ pub struct StatsCounters {
|
||||
pub fec_late_shards: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
pub media_bytes_received: AtomicU64,
|
||||
pub probe_packets_received: AtomicU64,
|
||||
pub probe_bytes_received: AtomicU64,
|
||||
pub probe_first_arrival_ns: AtomicU64,
|
||||
@@ -101,6 +112,7 @@ impl StatsCounters {
|
||||
fec_late_shards: self.fec_late_shards.load(l),
|
||||
bytes_sent: self.bytes_sent.load(l),
|
||||
bytes_received: self.bytes_received.load(l),
|
||||
media_bytes_received: self.media_bytes_received.load(l),
|
||||
probe_packets_received: self.probe_packets_received.load(l),
|
||||
probe_bytes_received: self.probe_bytes_received.load(l),
|
||||
probe_first_arrival_ns: self.probe_first_arrival_ns.load(l),
|
||||
|
||||
@@ -192,6 +192,11 @@ mod wasapi_mic;
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[path = "audio/wiring_plan.rs"]
|
||||
pub(crate) mod wiring_plan;
|
||||
// Pure capture-loop policy, split out for the same reason `wiring_plan` is: it encodes field
|
||||
// behaviour, so its tests must run on every platform's CI, not only Windows.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[path = "audio/capture_policy.rs"]
|
||||
pub(crate) mod capture_policy;
|
||||
|
||||
mod mic_jitter;
|
||||
mod mic_pump;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Desktop-audio capture POLICY — the parts of [`wasapi_cap`](super::wasapi_cap) that are pure
|
||||
//! decisions rather than WASAPI plumbing, split out for the same reason
|
||||
//! [`wiring_plan`](super::wiring_plan) is: so they compile and their unit tests RUN on every
|
||||
//! platform. Both of these encode field-report behaviour, and regressing either must fail CI on
|
||||
//! Linux too, not only on a Windows box.
|
||||
//!
|
||||
//! * [`FightDamper`] — how hard to fight another program for the default playback device.
|
||||
//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken
|
||||
//! endpoint from one we are damaging ourselves.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting.
|
||||
pub(crate) const FIGHT_LIMIT: u32 = 4;
|
||||
pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20);
|
||||
/// How long to leave the default alone once another program has proven it will take it back.
|
||||
pub(crate) const FIGHT_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Damping for the default-playback tug-of-war (WP2.4).
|
||||
///
|
||||
/// The 2026-08-03 field log recorded seven full re-assert cycles in sixteen seconds — something on
|
||||
/// that box re-set the default playback to CABLE Input every ~4 s and we snapped it back every
|
||||
/// time, each round a capture teardown plus a wiring pass with `IPolicyConfig` writes. Winning that
|
||||
/// argument is not possible and every round was an audible dropout, so: re-assert a few times
|
||||
/// (transient churn does settle), then concede for a minute and say so once.
|
||||
///
|
||||
/// Time is passed IN rather than read here, which keeps the policy pure and testable.
|
||||
pub(crate) struct FightDamper {
|
||||
/// Re-assertions in the current window, and when the window opened.
|
||||
count: u32,
|
||||
window_started: Instant,
|
||||
/// Set while we are deliberately not fighting.
|
||||
paused_until: Option<Instant>,
|
||||
/// One warning per fight burst, and one per concession.
|
||||
warned_fighting: bool,
|
||||
warned_giving_up: bool,
|
||||
now: Instant,
|
||||
}
|
||||
|
||||
impl FightDamper {
|
||||
pub(crate) fn new(now: Instant) -> FightDamper {
|
||||
FightDamper {
|
||||
count: 0,
|
||||
window_started: now,
|
||||
paused_until: None,
|
||||
warned_fighting: false,
|
||||
warned_giving_up: false,
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dud default-device change was observed at `now`.
|
||||
pub(crate) fn observed_at(&mut self, now: Instant) {
|
||||
self.now = now;
|
||||
if now.duration_since(self.window_started) >= FIGHT_WINDOW {
|
||||
self.window_started = now;
|
||||
self.count = 0;
|
||||
self.warned_fighting = false;
|
||||
}
|
||||
if self.paused_until.is_some_and(|t| now >= t) {
|
||||
self.paused_until = None;
|
||||
self.warned_giving_up = false;
|
||||
self.count = 0;
|
||||
self.window_started = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Should we put the default back? False while paused, or once this window's budget is spent.
|
||||
pub(crate) fn should_reassert(&mut self) -> bool {
|
||||
if self.paused_until.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.count >= FIGHT_LIMIT {
|
||||
self.paused_until = Some(self.now + FIGHT_BACKOFF);
|
||||
return false;
|
||||
}
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Warn on the FIRST re-assert of a burst only (the rest are noise).
|
||||
pub(crate) fn warn_now(&mut self) -> bool {
|
||||
!std::mem::replace(&mut self.warned_fighting, true)
|
||||
}
|
||||
|
||||
/// Warn once when we concede.
|
||||
pub(crate) fn warn_giving_up(&mut self) -> bool {
|
||||
self.paused_until.is_some() && !std::mem::replace(&mut self.warned_giving_up, true)
|
||||
}
|
||||
|
||||
/// Currently conceding (test/diagnostic accessor).
|
||||
pub(crate) fn is_paused(&self) -> bool {
|
||||
self.paused_until.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the capture loop reports its vitals (WP0.2).
|
||||
pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One reporting window's worth of capture vitals.
|
||||
///
|
||||
/// The point is to make three states that used to look identical in a log tell themselves apart: a
|
||||
/// genuinely quiet host (`peak` ~0, no drops), a working stream (`peak` > 0), and a stream we are
|
||||
/// damaging ourselves (`dropped_chunks` > 0). The 2026-08-03 field log — 3,600 lines, filed over an
|
||||
/// audio-quality complaint — could distinguish none of them, because the audio plane logged nothing
|
||||
/// at all between "capturing" and the session ending.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CaptureStats {
|
||||
pub(crate) frames: u64,
|
||||
/// Interleaved SAMPLES seen — the RMS denominator. Deliberately separate from `frames`:
|
||||
/// dividing the sum of squares by the frame count instead inflates RMS by sqrt(channels),
|
||||
/// which made a sine report an RMS equal to its own peak.
|
||||
pub(crate) samples: u64,
|
||||
/// Loudest |sample| in the window — tells a silent endpoint from a working one.
|
||||
pub(crate) peak: f32,
|
||||
/// Sum of squares, for the window's RMS: a level far below peak means a badly attenuated
|
||||
/// endpoint (a parked device sitting at 20 % volume costs ~14 dB before Opus ever sees it).
|
||||
pub(crate) sumsq: f64,
|
||||
/// Chunks the encode thread was too slow to take. Silent data loss, previously uncounted:
|
||||
/// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of
|
||||
/// everything after it.
|
||||
pub(crate) dropped_chunks: u64,
|
||||
}
|
||||
|
||||
impl CaptureStats {
|
||||
pub(crate) fn observe(&mut self, samples: &[f32], channels: u32) {
|
||||
self.frames += (samples.len() / channels.max(1) as usize) as u64;
|
||||
self.samples += samples.len() as u64;
|
||||
for &s in samples {
|
||||
let a = s.abs();
|
||||
if a > self.peak {
|
||||
self.peak = a;
|
||||
}
|
||||
self.sumsq += (s as f64) * (s as f64);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
|
||||
/// -inf so the log line stays parseable.
|
||||
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
|
||||
let rms = (self.sumsq / (self.samples as f64).max(1.0)).sqrt();
|
||||
let db = |v: f64| if v > 0.0 { 20.0 * v.log10() } else { -120.0 };
|
||||
// Expected frames for the window — a shortfall means the endpoint is not delivering at
|
||||
// real time (a stalling virtual device), which a peak/RMS alone cannot show.
|
||||
let expected = elapsed.as_secs_f64() * sample_rate as f64;
|
||||
(
|
||||
db(self.peak as f64),
|
||||
db(rms),
|
||||
(self.frames as f64 / expected.max(1.0)) * 100.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replays the 2026-08-03 field shape: a dud default change every ~2 s, forever. We must put
|
||||
/// the default back a few times, then concede — and warn exactly once for each.
|
||||
#[test]
|
||||
fn fight_damper_concedes_instead_of_looping_forever() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
let mut reasserts = 0;
|
||||
let (mut warns_fighting, mut warns_giving_up) = (0, 0);
|
||||
for i in 0..8 {
|
||||
d.observed_at(t0 + Duration::from_millis(i * 2_000));
|
||||
if d.should_reassert() {
|
||||
reasserts += 1;
|
||||
if d.warn_now() {
|
||||
warns_fighting += 1;
|
||||
}
|
||||
} else if d.warn_giving_up() {
|
||||
warns_giving_up += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
reasserts, FIGHT_LIMIT,
|
||||
"must stop after the window's budget"
|
||||
);
|
||||
assert_eq!(warns_fighting, 1, "one warning per burst, not one per flip");
|
||||
assert_eq!(warns_giving_up, 1, "concede exactly once");
|
||||
}
|
||||
|
||||
/// Occasional, genuinely transient churn must ALWAYS be corrected — the damper must not
|
||||
/// accumulate across widely-spaced events and quietly stop doing its job.
|
||||
#[test]
|
||||
fn fight_damper_always_fixes_isolated_changes() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
let mut reasserts = 0;
|
||||
for i in 1..=10 {
|
||||
d.observed_at(t0 + FIGHT_WINDOW * i);
|
||||
if d.should_reassert() {
|
||||
reasserts += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(reasserts, 10, "isolated changes must always be corrected");
|
||||
}
|
||||
|
||||
/// After the backoff expires the damper re-arms, so a program that goes quiet and comes back
|
||||
/// later is fought again rather than being conceded to for the rest of the session.
|
||||
#[test]
|
||||
fn fight_damper_rearms_after_the_backoff() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
for i in 0..FIGHT_LIMIT + 2 {
|
||||
d.observed_at(t0 + Duration::from_millis(i as u64 * 500));
|
||||
d.should_reassert();
|
||||
}
|
||||
assert!(d.is_paused(), "should have conceded");
|
||||
d.observed_at(t0 + FIGHT_BACKOFF + FIGHT_WINDOW * 2);
|
||||
assert!(d.should_reassert(), "must re-arm once the backoff expires");
|
||||
}
|
||||
|
||||
/// Peak/RMS must separate the states a log could not previously tell apart.
|
||||
#[test]
|
||||
fn capture_stats_separate_silence_from_signal() {
|
||||
let mut quiet = CaptureStats::default();
|
||||
quiet.observe(&[0.0; 480], 2);
|
||||
let (peak, rms, _) = quiet.summary(Duration::from_secs(1), 48_000);
|
||||
assert_eq!(peak, -120.0, "digital silence reports the floor, not -inf");
|
||||
assert_eq!(rms, -120.0);
|
||||
|
||||
let mut loud = CaptureStats::default();
|
||||
let tone: Vec<f32> = (0..480).map(|i| (i as f32 * 0.13).sin() * 0.5).collect();
|
||||
loud.observe(&tone, 2);
|
||||
assert_eq!(
|
||||
loud.frames, 240,
|
||||
"480 interleaved stereo samples = 240 frames"
|
||||
);
|
||||
let (peak, rms, _) = loud.summary(Duration::from_secs(1), 48_000);
|
||||
assert!(
|
||||
peak > -8.0 && peak <= 0.0,
|
||||
"peak {peak} dBFS should track a 0.5 tone"
|
||||
);
|
||||
// A sine's RMS is its amplitude / sqrt(2) — about 3 dB below peak. Getting this equal to
|
||||
// peak is exactly what a frames-vs-samples mix-up in the denominator looks like, so the
|
||||
// margin is asserted rather than just the ordering.
|
||||
assert!(
|
||||
rms < peak - 2.0,
|
||||
"RMS {rms} vs peak {peak}: a sine must sit ~3 dB below its peak"
|
||||
);
|
||||
}
|
||||
|
||||
/// The delivered-percentage is what shows an endpoint that has stopped feeding us in real
|
||||
/// time — invisible in peak/RMS, and the shape a stalling virtual device makes.
|
||||
#[test]
|
||||
fn capture_stats_report_a_delivery_shortfall() {
|
||||
let mut full = CaptureStats::default();
|
||||
full.observe(&vec![0.1f32; 48_000 * 2], 2); // exactly 1 s of stereo
|
||||
let (_, _, pct) = full.summary(Duration::from_secs(1), 48_000);
|
||||
assert!((pct - 100.0).abs() < 1.0, "expected ~100 %, got {pct}");
|
||||
|
||||
let mut half = CaptureStats::default();
|
||||
half.observe(&vec![0.1f32; 48_000], 2); // 0.5 s of stereo in a 1 s window
|
||||
let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000);
|
||||
assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}");
|
||||
}
|
||||
}
|
||||
@@ -674,6 +674,8 @@ fn pw_thread(
|
||||
})
|
||||
.register();
|
||||
|
||||
// Which source the negotiated format below actually describes — see the note there.
|
||||
let sink_mode = sink_name.is_some();
|
||||
let props = match &sink_name {
|
||||
// Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps
|
||||
// play into it, PipeWire mixes them, process() receives the mix. Mirrors the
|
||||
@@ -710,8 +712,25 @@ fn pw_thread(
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-audio", props)
|
||||
.context("pw audio Stream")?;
|
||||
|
||||
// The capture callback's state: the hand-off channel plus this plane's vitals. Before
|
||||
// this it was the bare `tx`, and the desktop-audio plane logged NOTHING between "capture
|
||||
// started" and the session ending — no level, no cadence, and in particular no sign of
|
||||
// the silent drop below. That is exactly what made the 2026-08-03 Windows field report
|
||||
// un-triageable, and the Linux half kept it after the Windows half was fixed.
|
||||
struct CapUd {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
channels: u32,
|
||||
stats: crate::audio::capture_policy::CaptureStats,
|
||||
last_stats: std::time::Instant,
|
||||
}
|
||||
let ud = CapUd {
|
||||
tx,
|
||||
channels,
|
||||
stats: Default::default(),
|
||||
last_stats: std::time::Instant::now(),
|
||||
};
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(tx)
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
@@ -723,22 +742,32 @@ fn pw_thread(
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(|_stream, _tx, id, param| {
|
||||
.param_changed(move |_stream, _tx, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let mut info = AudioInfoRaw::default();
|
||||
if info.parse(param).is_ok() {
|
||||
// `stream_sink` says WHICH source this format describes, and that changes how
|
||||
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
|
||||
// format apps render into and the desktop mix cannot have been narrowed before
|
||||
// we saw it. In LEGACY monitor mode we are capturing someone else's sink
|
||||
// through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would
|
||||
// still be reported here as a clean 48 kHz, exactly the way WASAPI's
|
||||
// autoconvert hid the same thing on Windows (the 2026-08-03 report). Reading
|
||||
// the monitored node's OWN rate needs a registry lookup this stream does not
|
||||
// do — recorded as an open gap rather than implied to be covered.
|
||||
tracing::info!(
|
||||
format = ?info.format(),
|
||||
rate = info.rate(),
|
||||
channels = info.channels(),
|
||||
stream_sink = sink_mode,
|
||||
"audio format negotiated"
|
||||
);
|
||||
}
|
||||
})
|
||||
.process(|stream, tx| {
|
||||
.process(|stream, ud| {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
@@ -774,7 +803,34 @@ fn pw_thread(
|
||||
];
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
let _ = tx.try_send(samples); // drop if the encoder is behind
|
||||
ud.stats.observe(&samples, ud.channels);
|
||||
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
|
||||
// encode thread is not keeping up, and because the encoder simply
|
||||
// concatenates across the hole every dropped chunk is a click AND a
|
||||
// permanent shift of everything after it.
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
ud.stats.dropped_chunks += 1;
|
||||
}
|
||||
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
||||
let (peak_db, rms_db, delivered_pct) =
|
||||
ud.stats.summary(ud.last_stats.elapsed(), SAMPLE_RATE);
|
||||
if ud.stats.dropped_chunks > 0 {
|
||||
tracing::warn!(
|
||||
dropped_chunks = ud.stats.dropped_chunks,
|
||||
"the audio encode thread could not keep up — captured audio was \
|
||||
DROPPED; the stream will click and everything after it shifts"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
peak_db = format!("{peak_db:.1}"),
|
||||
rms_db = format!("{rms_db:.1}"),
|
||||
delivered_pct = format!("{delivered_pct:.0}"),
|
||||
dropped_chunks = ud.stats.dropped_chunks,
|
||||
"desktop audio capture"
|
||||
);
|
||||
ud.stats = Default::default();
|
||||
ud.last_stats = std::time::Instant::now();
|
||||
}
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire audio callback — chunk dropped");
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
|
||||
//! is open (`set_playback` — the mic pump must never park the playback default while the host is
|
||||
//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so
|
||||
//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on
|
||||
//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! audio plays on the client only; `audio.output_mode = host_and_client` (formerly
|
||||
//! `PUNKTFUNK_HOST_AUDIO`) prefers real hardware instead (audible on both ends). Since 2026-08 a
|
||||
//! silent sink must also be able to CARRY the mix — one that narrows it (a voice-carrier endpoint
|
||||
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
|
||||
//! Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//!
|
||||
@@ -33,18 +36,44 @@
|
||||
//!
|
||||
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
|
||||
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
|
||||
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
|
||||
//! defaults untouched (the plan is still computed — the mic must still pick a target).
|
||||
//! same call `mmsys.cpl` makes). The `audio.output_mode = follow_default` setting (formerly
|
||||
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still
|
||||
//! computed, since the mic must still pick a target.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{self, plan, Endpoint, Wiring};
|
||||
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
use wasapi::Direction;
|
||||
|
||||
/// A render endpoint's engine mix format, or `None` if it cannot be asked right now.
|
||||
///
|
||||
/// This is the number the 2026-08-03 field report needed and no log had: the capture side requests
|
||||
/// 48 kHz f32 with `autoconvert`, so WASAPI converts silently from whatever the endpoint really
|
||||
/// runs — and a voice-carrier endpoint (Steam's Streaming Microphone) narrowing the desktop mix to
|
||||
/// mono or 24 kHz was invisible. Reading it costs one `IAudioClient` activation per endpoint, done
|
||||
/// only during a wiring pass.
|
||||
///
|
||||
/// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan
|
||||
/// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly
|
||||
/// as it did before formats existed, instead of mis-demoting a perfectly good endpoint.
|
||||
fn mix_format_of(ep: &Endpoint) -> Option<MixFormat> {
|
||||
let fmt = open_endpoint(ep)
|
||||
.ok()?
|
||||
.get_iaudioclient()
|
||||
.ok()?
|
||||
.get_mixformat()
|
||||
.ok()?;
|
||||
Some(MixFormat {
|
||||
rate_hz: fmt.get_samplespersec(),
|
||||
channels: fmt.get_nchannels(),
|
||||
bits: fmt.get_bitspersample(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
|
||||
fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
|
||||
let mut out = Vec::new();
|
||||
@@ -69,10 +98,22 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback
|
||||
/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior).
|
||||
/// The operator wants the stream audible on the host too — the loopback plan prefers real
|
||||
/// hardware over the silent sink (the pre-client-only-default behavior).
|
||||
///
|
||||
/// Now driven by the first-class `audio.output_mode` setting
|
||||
/// ([`AudioOutputMode`](pf_host_config::AudioOutputMode)), which still honours the older
|
||||
/// `PUNKTFUNK_HOST_AUDIO` spelling.
|
||||
pub(crate) fn host_audio_requested() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
|
||||
pf_host_config::config()
|
||||
.audio_output_mode
|
||||
.prefers_host_hardware()
|
||||
}
|
||||
|
||||
/// The operator's default playback/recording devices must not be touched at all — the
|
||||
/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`.
|
||||
pub(crate) fn keep_default_devices() -> bool {
|
||||
pf_host_config::config().audio_output_mode.keeps_default()
|
||||
}
|
||||
|
||||
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
|
||||
@@ -118,7 +159,27 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
// activation per render endpoint on every pass.
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
|
||||
&mix_format_of
|
||||
} else {
|
||||
&wiring_plan::no_formats
|
||||
};
|
||||
let wiring = plan_with_formats(
|
||||
&renders,
|
||||
&captures,
|
||||
want.as_deref(),
|
||||
host_audio_requested(),
|
||||
probe,
|
||||
// The loopback is opened at the session's negotiated channel count, but the wiring pass
|
||||
// runs before (and outside) any session. Stereo is the floor every session uses and the
|
||||
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
||||
// cannot carry stereo cannot carry 5.1 either.
|
||||
2,
|
||||
);
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
fingerprint,
|
||||
@@ -142,6 +203,18 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
"audio wiring plan"
|
||||
);
|
||||
// The quality warning the 2026-08-03 report had no way to produce. Says WHICH endpoint,
|
||||
// WHY it is narrow, and the two things the operator can actually do about it.
|
||||
if let (Some(why), Some((name, _))) = (&wiring.loopback_narrowing, &wiring.loopback_render)
|
||||
{
|
||||
tracing::warn!(
|
||||
device = %name,
|
||||
"the desktop-audio loopback endpoint {why} — streamed audio will sound worse \
|
||||
than it does on the host. Attach or select a 48 kHz stereo output device, or \
|
||||
set audio.output_mode = host_and_client (PUNKTFUNK_HOST_AUDIO=1) to prefer \
|
||||
real hardware"
|
||||
);
|
||||
}
|
||||
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
|
||||
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
|
||||
// static advice here suggested installing the Steam pair to a field box that had it
|
||||
@@ -153,10 +226,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
if keep_default_devices() {
|
||||
if changed {
|
||||
tracing::info!(
|
||||
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
|
||||
mode = %pf_host_config::config().audio_output_mode.as_str(),
|
||||
"audio output mode is follow_default — leaving the audio default devices untouched"
|
||||
);
|
||||
}
|
||||
return done(wiring);
|
||||
@@ -317,6 +391,25 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
|
||||
/// wiring pass (WP2.4).
|
||||
///
|
||||
/// The capture loop uses this when something else takes the default mid-stream: in Assert mode the
|
||||
/// capture is bound to the planned endpoint explicitly, so the only thing a hijacked default
|
||||
/// changes is where *apps* render — one `IPolicyConfig` write fixes that, where the old path tore
|
||||
/// the capture down and re-ran the whole wiring pass. Deliberately does not touch the [`PARKED`]
|
||||
/// memo: the endpoint is the one we already parked, so the operator's original default is
|
||||
/// unchanged and still owed back at stream end.
|
||||
pub(crate) fn reassert_default_playback(id: &str) -> bool {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "failed to re-assert the default playback device");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the operator's default playback device back after streaming — the inverse of
|
||||
/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed
|
||||
/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
|
||||
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
@@ -359,7 +360,7 @@ fn capture_once(
|
||||
) -> Result<Next> {
|
||||
// Interleaved f32: channels * 4 bytes per frame.
|
||||
let block_align = channels as usize * 4;
|
||||
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
|
||||
let keep_default = audio_control::keep_default_devices();
|
||||
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
|
||||
let assert_plan = mode == TargetMode::Assert && !keep_default;
|
||||
let mut plan = audio_control::wire_now_full(assert_plan);
|
||||
@@ -454,12 +455,25 @@ fn capture_once(
|
||||
channels as usize,
|
||||
Some(mask),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
// WP0.1 — the endpoint's ACTUAL engine mix format, read BEFORE we initialize. Everything the
|
||||
// old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI
|
||||
// silently converts from whatever the endpoint really runs, so a voice-carrier endpoint
|
||||
// narrowing the desktop mix to mono or 24 kHz was invisible in a 3,600-line field log. This
|
||||
// line is what makes an audio-quality report triageable without a round trip.
|
||||
let engine = audio_client.get_mixformat().ok();
|
||||
// NB the plan's WP4.5 ("open the loopback at the MINIMUM device period, worth ~5–10 ms") is
|
||||
// deliberately NOT done here, because its premise is wrong: in shared mode
|
||||
// `IAudioClient::Initialize` cannot change the engine period at all — `hnsBufferDuration` sizes
|
||||
// the buffer, and the callback still fires at the engine's fixed default period. Lowering it
|
||||
// needs `IAudioClient3::InitializeSharedAudioStream`, which the `wasapi` crate does not wrap.
|
||||
// Passing `min_period` here would therefore be a no-op at best and a new Initialize failure
|
||||
// path at worst, on a device this tree cannot compile for, let alone test. Left as real work.
|
||||
let (default_period, min_period) = audio_client.get_device_period().context("device period")?;
|
||||
let stream_mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
let used_period = default_period;
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Capture, &stream_mode)
|
||||
.context("initialize loopback client")?;
|
||||
@@ -476,7 +490,17 @@ fn capture_once(
|
||||
tracing::info!(device = %dev_name,
|
||||
follow = matches!(mode, TargetMode::Follow) || keep_default,
|
||||
last_resort,
|
||||
// The endpoint's own format — NOT the one we asked for.
|
||||
engine_hz = engine.as_ref().map(|f| f.get_samplespersec()),
|
||||
engine_ch = engine.as_ref().map(|f| f.get_nchannels()),
|
||||
engine_bits = engine.as_ref().map(|f| f.get_bitspersample()),
|
||||
buffer_ms = used_period as f32 / 10_000.0,
|
||||
min_buffer_ms = min_period as f32 / 10_000.0,
|
||||
"audio loopback capturing");
|
||||
if let Some(why) = &wiring.loopback_narrowing {
|
||||
tracing::warn!(device = %dev_name,
|
||||
"capturing an endpoint that {why} — the stream cannot sound better than this source");
|
||||
}
|
||||
|
||||
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
|
||||
// parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge
|
||||
@@ -514,6 +538,15 @@ fn capture_once(
|
||||
let opened_at = Instant::now();
|
||||
let mut saw_packets = false;
|
||||
let mut silence_noted = false;
|
||||
// WP0.2 — the audio plane's own vitals, logged periodically. Before this, a host log said
|
||||
// nothing whatsoever about audio between "capturing" and the session ending: no level, no
|
||||
// cadence, and in particular no sign of the SILENT, uncounted drop below, where a stalled
|
||||
// encode thread loses chunks and the encoder simply concatenates across the hole (a click,
|
||||
// and a permanent A/V offset, with nothing in any log).
|
||||
let mut stats = CaptureStats::default();
|
||||
let mut last_stats = Instant::now();
|
||||
// WP2.4 — damping for the default-playback tug-of-war.
|
||||
let mut fight = FightDamper::new(Instant::now());
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
audio_client.stop_stream().ok();
|
||||
@@ -556,7 +589,34 @@ fn capture_once(
|
||||
for c in raw.chunks_exact(4) {
|
||||
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
|
||||
stats.observe(&samples, channels);
|
||||
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
|
||||
// means the encode thread is not keeping up, and every dropped chunk is a click plus
|
||||
// a permanent shift of everything after it.
|
||||
if tx.try_send(samples).is_err() {
|
||||
stats.dropped_chunks += 1;
|
||||
}
|
||||
}
|
||||
if last_stats.elapsed() >= STATS_EVERY {
|
||||
let (peak_db, rms_db, delivered_pct) = stats.summary(last_stats.elapsed(), SAMPLE_RATE);
|
||||
if stats.dropped_chunks > 0 {
|
||||
tracing::warn!(
|
||||
device = %dev_name,
|
||||
dropped_chunks = stats.dropped_chunks,
|
||||
"the audio encode thread could not keep up — captured audio was DROPPED; the \
|
||||
stream will click and everything after it shifts"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
device = %dev_name,
|
||||
peak_db = format!("{peak_db:.1}"),
|
||||
rms_db = format!("{rms_db:.1}"),
|
||||
delivered_pct = format!("{delivered_pct:.0}"),
|
||||
dropped_chunks = stats.dropped_chunks,
|
||||
"desktop audio capture"
|
||||
);
|
||||
last_stats = Instant::now();
|
||||
stats = CaptureStats::default();
|
||||
}
|
||||
|
||||
// Watchdog: react when the default render device CHANGES from what we last observed —
|
||||
@@ -568,29 +628,68 @@ fn capture_once(
|
||||
if seen_default.as_deref() != Some(nid.as_str()) {
|
||||
seen_default = Some(nid.clone());
|
||||
if nid != dev_id {
|
||||
audio_client.stop_stream().ok();
|
||||
// NB the stream is stopped per-branch below, NOT here: the WP2.4 Dud
|
||||
// path deliberately keeps capturing, and stopping first would have made
|
||||
// the "no teardown" fix silently useless.
|
||||
if keep_default {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(
|
||||
"default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
|
||||
following it"
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
return Ok(match judge_default(&en, wiring, &nid) {
|
||||
match judge_default(&en, wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(device = %name,
|
||||
"operator changed the output device mid-stream — following \
|
||||
it (audio now also plays on the host)");
|
||||
Next::Reopen(TargetMode::Follow)
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
// WP2.4 — a DUD default does not affect what we are capturing:
|
||||
// Assert mode binds the capture to the plan's endpoint EXPLICITLY,
|
||||
// not to whatever the default happens to be. Only where *apps*
|
||||
// render has moved. So put the default back and KEEP THE STREAM —
|
||||
// the old full reopen tore the capture down for nothing, and the
|
||||
// 2026-08-03 field log shows what that cost: something re-set the
|
||||
// default to CABLE Input every ~4 s and each round trip was a
|
||||
// teardown, a re-plan with IPolicyConfig writes, and an audible
|
||||
// dropout — seven of them in sixteen seconds, one ending in a 2 s
|
||||
// error backoff.
|
||||
DefaultKind::Dud(name) => {
|
||||
tracing::warn!(device = %name,
|
||||
"default playback moved to an endpoint whose loopback cannot \
|
||||
work — re-asserting the audio wiring plan");
|
||||
Next::Reopen(TargetMode::Assert)
|
||||
if !assert_plan {
|
||||
// Follow/KEEP_DEFAULT shapes still need the old behaviour:
|
||||
// there the capture IS bound to the default.
|
||||
audio_client.stop_stream().ok();
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
fight.observed_at(Instant::now());
|
||||
if fight.should_reassert() {
|
||||
audio_control::reassert_default_playback(&dev_id);
|
||||
// Believe our own write: the next watchdog tick sees the
|
||||
// default back on our endpoint and stays quiet.
|
||||
seen_default = Some(dev_id.clone());
|
||||
if fight.warn_now() {
|
||||
tracing::warn!(device = %name, planned = %dev_name,
|
||||
"something keeps moving the default playback to an \
|
||||
endpoint whose loopback cannot work — putting it \
|
||||
back (the capture is unaffected)");
|
||||
}
|
||||
} else if fight.warn_giving_up() {
|
||||
tracing::warn!(device = %name, planned = %dev_name,
|
||||
backoff_s = FIGHT_BACKOFF.as_secs(),
|
||||
"another program is repeatedly taking the default \
|
||||
playback device — backing off rather than fighting it. \
|
||||
Desktop audio keeps streaming from the planned endpoint, \
|
||||
but apps rendering to the other device will not be heard");
|
||||
}
|
||||
}
|
||||
DefaultKind::Unknown => Next::Reopen(TargetMode::Assert),
|
||||
});
|
||||
DefaultKind::Unknown => {
|
||||
audio_client.stop_stream().ok();
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,59 @@
|
||||
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
|
||||
pub(crate) type Endpoint = (String, String);
|
||||
|
||||
/// A render endpoint's ENGINE MIX FORMAT, as `IAudioClient::GetMixFormat` reports it.
|
||||
///
|
||||
/// This is the number the 2026-08-03 field report needed and the log did not have. The capture
|
||||
/// side opens with `autoconvert: true` and asks for 48 kHz f32 in the wire layout, so WASAPI
|
||||
/// silently converts whatever the endpoint really runs — and the "48 kHz f32 channels=2" we
|
||||
/// logged was our REQUEST, not the source. An endpoint that mixes at 24 kHz mono therefore
|
||||
/// produced a 48 kHz stereo stream that had already been through a 24 kHz mono bottleneck, with
|
||||
/// nothing in any log to say so.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MixFormat {
|
||||
pub rate_hz: u32,
|
||||
pub channels: u16,
|
||||
pub bits: u16,
|
||||
}
|
||||
|
||||
impl MixFormat {
|
||||
/// Why this endpoint would NARROW a `want`-channel desktop mix, or `None` if it carries it
|
||||
/// intact. Bit depth is deliberately not a criterion: 16-bit is ~96 dB of headroom, far below
|
||||
/// Opus's own noise floor, whereas a lost channel or halved bandwidth is plainly audible.
|
||||
pub(crate) fn narrowing(&self, want: u8) -> Option<String> {
|
||||
if self.rate_hz < 48_000 && self.channels < want as u16 {
|
||||
return Some(format!(
|
||||
"mixes at {} Hz and only {} channel(s)",
|
||||
self.rate_hz, self.channels
|
||||
));
|
||||
}
|
||||
if self.rate_hz < 48_000 {
|
||||
return Some(format!(
|
||||
"mixes at {} Hz, so the stream is band-limited to ~{} kHz before Opus sees it",
|
||||
self.rate_hz,
|
||||
self.rate_hz / 2000
|
||||
));
|
||||
}
|
||||
if self.channels < want as u16 {
|
||||
return Some(format!(
|
||||
"mixes {} channel(s), so a {want}-channel desktop mix is downmixed and re-expanded",
|
||||
self.channels
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up a render endpoint's mix format by endpoint id. `None` = unknown (enumeration failed,
|
||||
/// or the caller has no way to ask) — treated as "assume it is fine", so a probe failure can
|
||||
/// never make the plan worse than it was before formats existed.
|
||||
pub(crate) type FormatProbe<'a> = &'a dyn Fn(&Endpoint) -> Option<MixFormat>;
|
||||
|
||||
/// A [`FormatProbe`] that knows nothing — the pre-WP2.1 behaviour.
|
||||
pub(crate) fn no_formats(_: &Endpoint) -> Option<MixFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture
|
||||
/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a
|
||||
/// once-per-process plan goes stale.
|
||||
@@ -60,6 +113,11 @@ pub(crate) struct Wiring {
|
||||
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
|
||||
/// materializes and re-plans on any endpoint-set change instead of riding it out.
|
||||
pub loopback_last_resort: bool,
|
||||
/// Set when the chosen loopback endpoint's mix format NARROWS the desktop mix (see
|
||||
/// [`MixFormat::narrowing`]) and the plan took it anyway because nothing better existed. Carries
|
||||
/// the human-readable reason for the capture side to log — a quality risk the operator can act
|
||||
/// on (attach a real output, or set the output mode to prefer hardware), not a failure.
|
||||
pub loopback_narrowing: Option<String>,
|
||||
}
|
||||
|
||||
impl Wiring {
|
||||
@@ -137,6 +195,32 @@ pub(crate) fn plan(
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
) -> Wiring {
|
||||
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
|
||||
}
|
||||
|
||||
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
||||
/// session wants to carry.
|
||||
///
|
||||
/// **The 2026-08-03 field report is this function's reason to exist.** The default client-only
|
||||
/// preference takes the "silent sink" — Steam's Streaming *Microphone* render endpoint — over real
|
||||
/// hardware unconditionally, because it is silent on the host. But that endpoint exists to carry
|
||||
/// remote *voice*, and nothing checked whether it could carry music. On the reporter's box it won
|
||||
/// all 31 loopback opens across 25 sessions while a clean AMD HD Audio endpoint sat idle, and the
|
||||
/// whole desktop mix went through it before reaching Opus.
|
||||
///
|
||||
/// So a silent sink now has to EARN its preference: if its mix format narrows the mix (see
|
||||
/// [`MixFormat::narrowing`]) it drops below real hardware. It is still taken when nothing better
|
||||
/// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the
|
||||
/// capture side can say why. An unknown format (probe failed) counts as fine, so this can never
|
||||
/// make the plan worse than it was before formats existed.
|
||||
pub(crate) fn plan_with_formats(
|
||||
renders: &[Endpoint],
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
format_of: FormatProbe,
|
||||
want_channels: u8,
|
||||
) -> Wiring {
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
@@ -172,10 +256,18 @@ pub(crate) fn plan(
|
||||
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
};
|
||||
let silent = || {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
|
||||
// A silent sink splits in two: one that carries the mix intact, and one that narrows it. The
|
||||
// first keeps the historical preference; the second falls BELOW real hardware.
|
||||
let narrowing_of = |ep: &Endpoint| format_of(ep).and_then(|f| f.narrowing(want_channels));
|
||||
let silent_intact = || {
|
||||
renders.iter().find(|ep| {
|
||||
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_none()
|
||||
})
|
||||
};
|
||||
let silent_narrow = || {
|
||||
renders.iter().find(|ep| {
|
||||
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_some()
|
||||
})
|
||||
};
|
||||
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
|
||||
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
|
||||
@@ -192,10 +284,13 @@ pub(crate) fn plan(
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
|
||||
};
|
||||
// A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the
|
||||
// host is a routing choice, but it must not silently cost audio quality when a clean endpoint
|
||||
// is right there.
|
||||
let preferred = if host_audio {
|
||||
real_hw().or_else(silent)
|
||||
real_hw().or_else(silent_intact).or_else(silent_narrow)
|
||||
} else {
|
||||
silent().or_else(real_hw)
|
||||
silent_intact().or_else(real_hw).or_else(silent_narrow)
|
||||
};
|
||||
let (loopback_render, loopback_last_resort) = match preferred {
|
||||
Some(ep) => (Some(ep.clone()), false),
|
||||
@@ -204,12 +299,16 @@ pub(crate) fn plan(
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
// Report narrowing for whatever we actually chose — including real hardware, which can also
|
||||
// be a 24 kHz mono endpoint (a headset's hands-free profile is exactly that).
|
||||
let loopback_narrowing = loopback_render.as_ref().and_then(narrowing_of);
|
||||
|
||||
Wiring {
|
||||
mic_render,
|
||||
mic_capture,
|
||||
loopback_render,
|
||||
loopback_last_resort,
|
||||
loopback_narrowing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +649,169 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- format-aware loopback selection (WP2.1) -----------------------------------------
|
||||
|
||||
fn fmt(rate_hz: u32, channels: u16) -> MixFormat {
|
||||
MixFormat {
|
||||
rate_hz,
|
||||
channels,
|
||||
bits: 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe helper: give endpoints whose (lowercased) name contains a needle that format,
|
||||
/// everything else unknown. Owns its table so call sites can pass a literal inline.
|
||||
fn probe(table: Vec<(&'static str, MixFormat)>) -> impl Fn(&Endpoint) -> Option<MixFormat> {
|
||||
move |ep: &Endpoint| {
|
||||
let name = ep.0.to_lowercase();
|
||||
table
|
||||
.iter()
|
||||
.find_map(|(needle, f)| name.contains(needle).then_some(*f))
|
||||
}
|
||||
}
|
||||
|
||||
/// THE 2026-08-03 field case, with formats. The reporter's exact endpoint inventory: the plan
|
||||
/// took the Steam Streaming Microphone on all 31 opens while a clean AMD HD Audio endpoint sat
|
||||
/// idle. Once we can see that the silent sink narrows the mix, real hardware must win.
|
||||
#[test]
|
||||
fn narrowing_silent_sink_loses_to_real_hardware() {
|
||||
let renders = [
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
ep("Altavoces (Steam Streaming Speakers)"),
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("1 - Odyssey G60SD (AMD High Definition Audio Device)"),
|
||||
];
|
||||
let captures = [
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
// A voice-carrier endpoint: 24 kHz mono.
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("odyssey", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
|
||||
"a narrowing silent sink must not beat clean real hardware"
|
||||
);
|
||||
assert!(
|
||||
w.loopback_narrowing.is_none(),
|
||||
"the chosen endpoint is intact"
|
||||
);
|
||||
// The mic assignment is untouched by any of this.
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
);
|
||||
}
|
||||
|
||||
/// …but a silent sink that carries the mix intact keeps its historical preference: the
|
||||
/// client-only routing default is not being abandoned, only made conditional on quality.
|
||||
#[test]
|
||||
fn intact_silent_sink_still_wins() {
|
||||
let renders = [
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(48_000, 2)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Narrow audio still beats NO audio: with nothing else available the narrowing sink is taken
|
||||
/// and flagged, not refused.
|
||||
#[test]
|
||||
fn narrowing_sink_is_taken_when_it_is_all_there_is() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
let why = w.loopback_narrowing.expect("must be flagged");
|
||||
assert!(why.contains("16000"), "{why}");
|
||||
}
|
||||
|
||||
/// Real hardware can narrow too — a headset in its hands-free profile is 16 kHz mono — and
|
||||
/// must be flagged just the same. The flag is about the CHOSEN endpoint, not about which tier
|
||||
/// it came from.
|
||||
#[test]
|
||||
fn narrowing_is_reported_for_real_hardware_too() {
|
||||
let renders = [ep("Headset (Hands-Free AG Audio)")];
|
||||
let p = probe(vec![("headset", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Headset (Hands-Free AG Audio)"
|
||||
);
|
||||
assert!(w.loopback_narrowing.is_some());
|
||||
}
|
||||
|
||||
/// An unknown format must never make the plan WORSE than it was before formats existed: a
|
||||
/// probe that answers nothing has to reproduce `plan` exactly.
|
||||
#[test]
|
||||
fn unknown_formats_reproduce_the_formatless_plan() {
|
||||
let renders = [
|
||||
ep("Speakers (Apple Audio Device)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let a = plan(&renders, &captures, None, host_audio);
|
||||
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
|
||||
assert_eq!(a, b, "host_audio={host_audio}");
|
||||
assert!(a.loopback_narrowing.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// `host_audio` still prefers real hardware, and a narrowing silent sink stays last in that
|
||||
/// mode too.
|
||||
#[test]
|
||||
fn host_audio_ordering_survives_formats() {
|
||||
let renders = [
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
|
||||
/// The narrowing test is channel-count aware: an endpoint that is fine for stereo narrows a
|
||||
/// 5.1 session.
|
||||
#[test]
|
||||
fn narrowing_depends_on_the_session_channel_count() {
|
||||
let stereo_only = fmt(48_000, 2);
|
||||
assert_eq!(stereo_only.narrowing(2), None);
|
||||
assert!(stereo_only.narrowing(6).is_some());
|
||||
// Rate is judged independently of channels.
|
||||
assert!(fmt(44_100, 8).narrowing(2).is_some());
|
||||
// And an endpoint wider than the session is never "narrowing".
|
||||
assert_eq!(fmt(48_000, 8).narrowing(2), None);
|
||||
// Both wrong: the message must name both problems.
|
||||
let both = fmt(16_000, 1).narrowing(6).unwrap();
|
||||
assert!(both.contains("16000") && both.contains("channel"), "{both}");
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
|
||||
@@ -110,6 +110,11 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta
|
||||
// is an authoritative "pointer hidden" — the caller derives it from the backend that created
|
||||
// `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own
|
||||
// backend). See `pf_capture`'s `cursor_id0_hides` contract.
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
|
||||
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
|
||||
@@ -132,6 +137,7 @@ pub fn capture_virtual_output(
|
||||
want.hdr,
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
vout.expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -171,6 +177,9 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no
|
||||
// `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source.
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -285,6 +294,7 @@ pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> {
|
||||
vout,
|
||||
fmt,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")?;
|
||||
cap.set_active(true);
|
||||
|
||||
@@ -570,6 +570,7 @@ fn open_gs_mirror_source(
|
||||
vout,
|
||||
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")
|
||||
}
|
||||
@@ -782,6 +783,7 @@ fn open_gs_virtual_source(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
|
||||
@@ -77,6 +77,32 @@ impl LogRing {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.push_entry(level.to_string(), target.to_string(), msg, ts_ms);
|
||||
}
|
||||
|
||||
/// Ingest a line that was produced in **another process** — the plugin/script runner, via
|
||||
/// `POST /plugins/logs` (see `mgmt::plugins::ingest_plugin_logs`).
|
||||
///
|
||||
/// Plugins are not host child processes: the runner is a separate bun process that `import()`s
|
||||
/// each plugin in-process, so a plugin's output never passes through this process's `tracing`
|
||||
/// and [`RingLayer`] can't see it. Without this door the console's log page shows nothing about
|
||||
/// the plugins at all, and on Windows nothing else does either — the runner task writes no log
|
||||
/// file, so a failing plugin was diagnosable only by stopping the task and re-running it by
|
||||
/// hand (field report 2026-08-03, the VirtualHere plugin).
|
||||
///
|
||||
/// The caller's `ts_ms` is kept — the line was stamped when it happened, and re-stamping it on
|
||||
/// arrival would collapse a whole batch onto the moment it was flushed. `seq` stays ours: it is
|
||||
/// the cursor for a single ring with several producers, so only the ring can mint it.
|
||||
pub fn push_remote(&self, level: &str, target: &str, msg: &str, ts_ms: u64) {
|
||||
self.push_entry(
|
||||
normalize_level(level).to_string(),
|
||||
target.to_string(),
|
||||
truncate_msg(msg.to_string()),
|
||||
ts_ms,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_entry(&self, level: String, target: String, msg: String, ts_ms: u64) {
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let seq = inner.next_seq;
|
||||
inner.next_seq += 1;
|
||||
@@ -86,8 +112,8 @@ impl LogRing {
|
||||
inner.entries.push_back(LogEntry {
|
||||
seq,
|
||||
ts_ms,
|
||||
level: level.to_string(),
|
||||
target: target.to_string(),
|
||||
level,
|
||||
target,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
@@ -125,6 +151,33 @@ pub fn ring() -> &'static LogRing {
|
||||
RING.get_or_init(LogRing::new)
|
||||
}
|
||||
|
||||
/// Coerce an externally-supplied level to the five the console's filter ranks. Anything else —
|
||||
/// a plugin inventing `NOTICE`, a truncated line, empty — becomes `INFO` rather than being
|
||||
/// rejected: an unfamiliar level is not a reason to drop the operator's diagnostics on the floor,
|
||||
/// and an unranked string would sort as `0` in the console's `RANK` map and hide under every filter.
|
||||
fn normalize_level(level: &str) -> &'static str {
|
||||
match level.trim().to_ascii_uppercase().as_str() {
|
||||
"ERROR" | "FATAL" | "SEVERE" => "ERROR",
|
||||
"WARN" | "WARNING" => "WARN",
|
||||
"DEBUG" => "DEBUG",
|
||||
"TRACE" | "VERBOSE" => "TRACE",
|
||||
_ => "INFO",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap a message at [`MAX_MSG`], cutting on a char boundary and marking the elision.
|
||||
fn truncate_msg(mut msg: String) -> String {
|
||||
if msg.len() > MAX_MSG {
|
||||
let mut end = MAX_MSG;
|
||||
while !msg.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
msg.truncate(end);
|
||||
msg.push('…');
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict
|
||||
/// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty
|
||||
/// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs
|
||||
@@ -223,15 +276,7 @@ impl FieldFmt {
|
||||
} else {
|
||||
self.msg.push_str(&self.fields);
|
||||
}
|
||||
if self.msg.len() > MAX_MSG {
|
||||
let mut end = MAX_MSG;
|
||||
while !self.msg.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
self.msg.truncate(end);
|
||||
self.msg.push('…');
|
||||
}
|
||||
self.msg
|
||||
truncate_msg(self.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +405,45 @@ mod tests {
|
||||
assert!(page.entries.iter().any(|e| e.target == "mdns_sdx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_entries_keep_their_own_timestamp_and_share_the_cursor() {
|
||||
let ring = LogRing::new();
|
||||
ring.push(&tracing::Level::INFO, "punktfunk_host", "local".into());
|
||||
ring.push_remote("WARN", "plugin:virtualhere", "remote", 1_700_000_000_123);
|
||||
|
||||
let page = ring.since(0, 10);
|
||||
assert_eq!(page.entries.len(), 2);
|
||||
// One sequence across both producers — the console's cursor cannot see two rings.
|
||||
assert_eq!(page.entries[0].seq, 1);
|
||||
assert_eq!(page.entries[1].seq, 2);
|
||||
let remote = &page.entries[1];
|
||||
assert_eq!(remote.level, "WARN");
|
||||
assert_eq!(remote.target, "plugin:virtualhere");
|
||||
assert_eq!(remote.msg, "remote");
|
||||
// Stamped when it happened, not when the batch arrived.
|
||||
assert_eq!(remote.ts_ms, 1_700_000_000_123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_levels_are_coerced_not_rejected() {
|
||||
assert_eq!(normalize_level("error"), "ERROR");
|
||||
assert_eq!(normalize_level(" Warning "), "WARN");
|
||||
assert_eq!(normalize_level("TRACE"), "TRACE");
|
||||
// An unranked level would sort as 0 in the console's filter and hide under every setting.
|
||||
assert_eq!(normalize_level("NOTICE"), "INFO");
|
||||
assert_eq!(normalize_level(""), "INFO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_messages_are_truncated_like_local_ones() {
|
||||
let ring = LogRing::new();
|
||||
ring.push_remote("INFO", "plugin:x", &"ä".repeat(MAX_MSG), 1);
|
||||
let page = ring.since(0, 10);
|
||||
let msg = &page.entries[0].msg;
|
||||
assert!(msg.ends_with('…'));
|
||||
assert!(msg.len() <= MAX_MSG + '…'.len_utf8());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_truncation_keeps_char_boundary() {
|
||||
let f = FieldFmt {
|
||||
|
||||
@@ -253,6 +253,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(plugins::list_plugins))
|
||||
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
|
||||
.routes(routes!(plugins::get_ui_credential))
|
||||
.routes(routes!(plugins::ingest_plugin_logs))
|
||||
.routes(routes!(store::get_catalog))
|
||||
.routes(routes!(store::refresh_catalog))
|
||||
.routes(routes!(store::list_installed))
|
||||
|
||||
@@ -29,6 +29,12 @@ use std::time::{Duration, Instant};
|
||||
/// this tolerates two missed ticks before a plugin drops out of the listing.
|
||||
const LEASE_TTL: Duration = Duration::from_secs(90);
|
||||
|
||||
/// Lines accepted per `POST /plugins/logs`. The runner batches on a short timer, so a batch this
|
||||
/// size means a plugin is logging faster than the ring can usefully hold — the shipper drops its
|
||||
/// own backlog (and says so in a line of its own) rather than letting one chatty plugin evict the
|
||||
/// whole ring in a single request.
|
||||
const MAX_LOG_BATCH: usize = 256;
|
||||
|
||||
// ---------------------------------------------------------------- wire shapes
|
||||
|
||||
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
|
||||
@@ -60,6 +66,26 @@ pub(crate) struct PluginRegistration {
|
||||
pub ui: Option<PluginUi>,
|
||||
}
|
||||
|
||||
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct PluginLogLine {
|
||||
/// When the line was produced, unix milliseconds. Kept verbatim — see
|
||||
/// [`crate::log_capture::LogRing::push_remote`].
|
||||
pub ts_ms: u64,
|
||||
/// `ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`.
|
||||
pub level: String,
|
||||
/// Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.
|
||||
/// Surfaced in the console's target column as `plugin:<source>`.
|
||||
pub source: String,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// A batch of runner log lines.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct PluginLogBatch {
|
||||
pub entries: Vec<PluginLogLine>,
|
||||
}
|
||||
|
||||
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PluginUiPublic {
|
||||
@@ -264,6 +290,26 @@ fn sanitize(s: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The console target for a runner-supplied line: `plugin:<source>`.
|
||||
///
|
||||
/// The source is NOT a [`valid_plugin_id`] — the runner names a unit by its `definePlugin` name
|
||||
/// (`virtualhere`), its package name (`@punktfunk/plugin-virtualhere`), a bare script's file stem,
|
||||
/// or `runner` for its own supervision lines, and all four are worth telling apart in the log. So
|
||||
/// this sanitizes rather than validates: control characters go (a log target is rendered in a
|
||||
/// terminal by `logs download` as readily as in the console), length is capped, and an empty source
|
||||
/// becomes `runner` so a line is never attributed to nothing.
|
||||
fn log_target(source: &str) -> String {
|
||||
let mut s = sanitize(source);
|
||||
if s.is_empty() {
|
||||
s = "runner".into();
|
||||
}
|
||||
// Cap on CHARS, not bytes — truncating a multi-byte name mid-sequence would panic.
|
||||
if s.chars().count() > 64 {
|
||||
s = s.chars().take(64).collect();
|
||||
}
|
||||
format!("plugin:{s}")
|
||||
}
|
||||
|
||||
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
|
||||
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||
let title = sanitize(®.title);
|
||||
@@ -367,6 +413,63 @@ pub(crate) async fn register_plugin(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Ingest runner log lines
|
||||
///
|
||||
/// The plugin/script runner ships its output here so the console's **Logs** page can show it.
|
||||
///
|
||||
/// Plugins are not host child processes — the runner is a separate `bun` process that `import()`s
|
||||
/// each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and
|
||||
/// before this endpoint the console's log page could not show a single plugin line. On Linux the
|
||||
/// fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no
|
||||
/// log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and
|
||||
/// re-running the runner by hand. Both are shell access on the host box, which is exactly what the
|
||||
/// console exists to avoid.
|
||||
///
|
||||
/// Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted
|
||||
/// `plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/plugins/logs",
|
||||
tag = "plugins",
|
||||
operation_id = "ingestPluginLogs",
|
||||
request_body = PluginLogBatch,
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Lines ingested"),
|
||||
(status = BAD_REQUEST, description = "Batch too large", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn ingest_plugin_logs(ApiJson(batch): ApiJson<PluginLogBatch>) -> Response {
|
||||
if batch.entries.len() > MAX_LOG_BATCH {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("at most {MAX_LOG_BATCH} entries per batch"),
|
||||
);
|
||||
}
|
||||
for line in batch.entries {
|
||||
crate::log_capture::ring().push_remote(
|
||||
&line.level,
|
||||
&log_target(&line.source),
|
||||
&sanitize_msg(&line.msg),
|
||||
line.ts_ms,
|
||||
);
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Strip control characters from an ingested message, keeping tabs.
|
||||
///
|
||||
/// Same reasoning as [`sanitize`], one exception wider: a plugin's messages routinely carry a
|
||||
/// stack trace or a vendor CLI's output, and an embedded newline would let one line forge several
|
||||
/// in the downloaded log file. Tabs survive because they are load-bearing in that kind of output.
|
||||
fn sanitize_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c == '\t' || !c.is_control() { c } else { ' ' })
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// List registered plugins
|
||||
///
|
||||
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
|
||||
|
||||
@@ -620,6 +620,23 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
// Log ingest. This is the ONLY token the scripting runner holds (on Windows its LocalService
|
||||
// principal cannot even read the admin one), so if this lane ever stopped reaching this route
|
||||
// the console's plugin logs would go quiet with nothing else failing — pin it here rather than
|
||||
// rely on `plugin_may_access`'s denylist continuing to not match `/plugins/logs`.
|
||||
let body = serde_json::json!({"entries": [{
|
||||
"ts_ms": 1_700_000_000_000u64,
|
||||
"level": "INFO",
|
||||
"source": "virtualhere",
|
||||
"msg": "hello from the runner",
|
||||
}]});
|
||||
let req = axum::http::Request::post("/api/v1/plugins/logs")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer plugin-secret")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT);
|
||||
|
||||
// The carve-outs answer 403 (authenticated but not authorized), not 401.
|
||||
for (method, path) in [
|
||||
(Method::GET, "/api/v1/hooks"),
|
||||
@@ -972,6 +989,59 @@ async fn plugin_registry_roundtrip() {
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Runner log ingest: lines reach the same ring `GET /logs` serves, tagged so the console can tell
|
||||
/// them from the host's own, and one chatty plugin can't evict the ring in a single request.
|
||||
#[tokio::test]
|
||||
async fn plugin_log_ingest_lands_in_the_ring() {
|
||||
let app = test_app(test_state(), None);
|
||||
let marker = "vh-ingest-marker-3f9a";
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/plugins/logs",
|
||||
serde_json::json!({"entries": [
|
||||
{"ts_ms": 1_700_000_000_123u64, "level": "warn", "source": "virtualhere", "msg": marker},
|
||||
// No source: attributed to the runner rather than to nothing.
|
||||
{"ts_ms": 1_700_000_000_124u64, "level": "NOTICE", "source": "", "msg": "orphan"},
|
||||
]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/logs?limit=1000")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let entries = body["entries"].as_array().unwrap();
|
||||
|
||||
let mine = entries
|
||||
.iter()
|
||||
.find(|e| e["msg"] == marker)
|
||||
.expect("ingested line is served by GET /logs");
|
||||
// `plugin:` is what the console's Host/Plugins filter keys on.
|
||||
assert_eq!(mine["target"], "plugin:virtualhere");
|
||||
// Lowercase in, canonical out — the console ranks these five and nothing else.
|
||||
assert_eq!(mine["level"], "WARN");
|
||||
// Stamped when the line happened, not when the batch arrived.
|
||||
assert_eq!(mine["ts_ms"], 1_700_000_000_123u64);
|
||||
|
||||
let orphan = entries.iter().find(|e| e["msg"] == "orphan").unwrap();
|
||||
assert_eq!(orphan["target"], "plugin:runner");
|
||||
// An unranked level would sort as 0 in the console's filter and hide under every setting.
|
||||
assert_eq!(orphan["level"], "INFO");
|
||||
|
||||
// An oversized batch is refused whole rather than half-ingested.
|
||||
let big: Vec<serde_json::Value> = (0..300)
|
||||
.map(|i| serde_json::json!({"ts_ms": 1u64, "level": "INFO", "source": "x", "msg": format!("f{i}")}))
|
||||
.collect();
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/plugins/logs", serde_json::json!({"entries": big})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
||||
/// on both), and the checked-in copy is current.
|
||||
#[test]
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::{
|
||||
mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
|
||||
};
|
||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||
use punktfunk_core::quic::{
|
||||
@@ -72,6 +70,9 @@ use input::{input_thread, ClientInput};
|
||||
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
|
||||
/// after the pairing gate.
|
||||
mod handshake;
|
||||
/// MTU resilience for the video data plane: `PUNKTFUNK_WIRE_MTU` override, the per-session
|
||||
/// path-MTU watch on the control connection, and the per-peer learned shard-payload clamp.
|
||||
mod wire_mtu;
|
||||
|
||||
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
|
||||
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
|
||||
@@ -1073,6 +1074,17 @@ async fn serve_session(
|
||||
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
|
||||
let (reconfig_result_tx, reconfig_result_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
|
||||
// Unsolicited bitrate re-target, data plane → control task (the `reconfig_result_tx` pattern
|
||||
// again, for the same reason). A pipeline rebuild can RE-RESOLVE an Automatic rate — most
|
||||
// visibly when the source delivers a different size than the session negotiated, e.g. a
|
||||
// client that asked for 1080p mirroring a 4K panel — and that number is what everything
|
||||
// downstream reasons about: the send pacer, the console, and the base a `SetBitrate` ack is
|
||||
// measured against. The client's copy only ever moved on an ack, so it stayed on the
|
||||
// negotiated rate while the host encoded at another one, and the ABR's first climb computed
|
||||
// from that stale base asked for LESS than the host was already sending — a re-target
|
||||
// downward, with the rebuild it costs. Tell the client instead; `BitrateChanged` already
|
||||
// means exactly this and old clients already handle one arriving unprompted.
|
||||
let (retarget_tx, retarget_rx) = tokio::sync::mpsc::unbounded_channel::<u32>();
|
||||
// Cursor-forward bridge (M2): the encode loop diffs each frame's cursor serial and hands
|
||||
// changed SHAPES here; the control task (the control stream's sole writer) sends them.
|
||||
// Same shape as `probe_result_tx`. Wired even when the channel wasn't negotiated — it
|
||||
@@ -1133,6 +1145,7 @@ async fn serve_session(
|
||||
probe_tx,
|
||||
probe_result_rx,
|
||||
reconfig_result_rx,
|
||||
retarget_rx,
|
||||
cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled,
|
||||
@@ -1295,9 +1308,18 @@ async fn serve_session(
|
||||
let stop = stop.clone();
|
||||
let cap = audio_cap.clone();
|
||||
let channels = welcome.audio_channels;
|
||||
// Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire
|
||||
// the client was promised and the wire we actually send cannot disagree — then re-derive
|
||||
// the SAME budget rung from it, so the encode tier and the redundancy decision are one
|
||||
// choice made once rather than two settings that can drift apart.
|
||||
let budget = handshake::audio_budget(
|
||||
welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0,
|
||||
welcome.bitrate_kbps,
|
||||
channels,
|
||||
);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-audio".into())
|
||||
.spawn(move || audio_thread(conn, stop, cap, channels))
|
||||
.spawn(move || audio_thread(conn, stop, cap, channels, budget))
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
|
||||
.ok()
|
||||
} else {
|
||||
@@ -1579,6 +1601,7 @@ async fn serve_session(
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
retarget_tx,
|
||||
fec_target: fec_target_dp,
|
||||
phase: phase_ctl,
|
||||
conn: conn_stream,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
|
||||
//! (48 kHz, 5 ms, CBR — the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
|
||||
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
|
||||
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
|
||||
//! stub, so a dev build streams video-only rather than failing to compile.
|
||||
//! (48 kHz, 5 ms, constrained VBR at the configured [`AudioTier`](punktfunk_core::audio::AudioTier))
|
||||
//! → `AUDIO_MAGIC` QUIC datagrams — or `AUDIO_RED_MAGIC` when the session negotiated redundancy —
|
||||
//! at the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send
|
||||
//! loop ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets
|
||||
//! get the stub, so a dev build streams video-only rather than failing to compile.
|
||||
//!
|
||||
//! Two things here deliberately DIVERGE from the GameStream plane, which used to share this
|
||||
//! tuning: hard CBR (its audio FEC needs fixed-size packets; this plane has no FEC, so CBR was a
|
||||
//! pure quality tax) and the fixed 128 kbps stereo bitrate. See [`NativeAudioEnc::new`].
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -17,20 +22,36 @@ enum NativeAudioEnc {
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl NativeAudioEnc {
|
||||
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
|
||||
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
|
||||
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
|
||||
/// Build the encoder for `channels` (2/6/8) at `tier`, RESTRICTED_LOWDELAY like the GameStream
|
||||
/// path but — unlike it — in CONSTRAINED VBR.
|
||||
///
|
||||
/// **Why not hard CBR (WP1.2).** The layout table's comment justifies `set_vbr(false)` with
|
||||
/// "constant packet size, which GameStream's audio FEC relies on" — true of the GameStream
|
||||
/// plane, and irrelevant here: the native `punktfunk/1` audio plane has no FEC at all (see
|
||||
/// `punktfunk_core::audio::AudioGapTracker`, which exists precisely because a lost packet has
|
||||
/// nothing to rebuild it from). So this path was paying a pure quality tax for a constraint
|
||||
/// that does not apply to it. Constrained VBR keeps the same average bitrate and the same
|
||||
/// bounded packet size, and spends the bits where the signal needs them.
|
||||
///
|
||||
/// The GameStream encoder (`crate::gamestream::audio`) is deliberately NOT changed: its FEC
|
||||
/// really does need fixed-size packets.
|
||||
fn new(
|
||||
channels: u8,
|
||||
tier: punktfunk_core::audio::AudioTier,
|
||||
) -> Result<NativeAudioEnc, opus::Error> {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let bitrate = l.bitrate_for(tier);
|
||||
if channels == 2 {
|
||||
let mut e = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
|
||||
e.set_vbr(true).ok();
|
||||
e.set_vbr_constraint(true).ok();
|
||||
Ok(NativeAudioEnc::Stereo(e))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let mut e = opus::MSEncoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
l.streams,
|
||||
@@ -38,8 +59,9 @@ impl NativeAudioEnc {
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
|
||||
e.set_vbr(true).ok();
|
||||
e.set_vbr_constraint(true).ok();
|
||||
Ok(NativeAudioEnc::Surround(e))
|
||||
}
|
||||
}
|
||||
@@ -52,8 +74,8 @@ impl NativeAudioEnc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
|
||||
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, constrained VBR at the configured
|
||||
/// tier) → `AUDIO_MAGIC` (or `AUDIO_RED_MAGIC`) datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
|
||||
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
@@ -62,11 +84,16 @@ pub(super) fn audio_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
audio_cap: AudioCapSlot,
|
||||
channels: u8,
|
||||
budget: punktfunk_core::audio::AudioBudget,
|
||||
) {
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
const FRAME_MS: usize = 5;
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
// Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see
|
||||
// `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there
|
||||
// and fell back to the default, so nothing here can silently downgrade someone's audio.
|
||||
let (tier, redundancy) = (budget.tier, budget.redundancy);
|
||||
|
||||
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
|
||||
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
|
||||
@@ -92,7 +119,7 @@ pub(super) fn audio_thread(
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut enc = match NativeAudioEnc::new(want) {
|
||||
let mut enc = match NativeAudioEnc::new(want, tier) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
|
||||
@@ -120,9 +147,16 @@ pub(super) fn audio_thread(
|
||||
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
|
||||
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
|
||||
let mut opus_encode_errs: u64 = 0;
|
||||
// WP3.1 — the previous frame's Opus bytes, for the redundant `0xD2` plane. Cleared whenever
|
||||
// continuity breaks (a capture reopen), so we never advertise a predecessor the client's
|
||||
// sequence numbering does not agree with.
|
||||
let mut prev_frame: Vec<u8> = Vec::new();
|
||||
if capturer.is_some() {
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
tier = tier.as_str(),
|
||||
kbps = budget.kbps,
|
||||
redundancy,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
}
|
||||
@@ -138,6 +172,10 @@ pub(super) fn audio_thread(
|
||||
capturer = Some(c);
|
||||
last_failed = None;
|
||||
acc.clear(); // drop the partial frame straddling the gap
|
||||
// The next frame has no valid predecessor across the gap: sending the
|
||||
// pre-gap frame as "the previous one" would hand the client audio from
|
||||
// before the discontinuity to splice in.
|
||||
prev_frame.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
|
||||
@@ -162,11 +200,24 @@ pub(super) fn audio_thread(
|
||||
let pts_ns = now_ns();
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d =
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
|
||||
let opus = &opus_buf[..n];
|
||||
let d = if redundancy {
|
||||
punktfunk_core::quic::encode_audio_red_datagram(
|
||||
seq,
|
||||
pts_ns,
|
||||
opus,
|
||||
&prev_frame,
|
||||
)
|
||||
} else {
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, opus)
|
||||
};
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
break 'session; // connection gone
|
||||
}
|
||||
if redundancy {
|
||||
prev_frame.clear();
|
||||
prev_frame.extend_from_slice(opus);
|
||||
}
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -199,6 +250,7 @@ pub(super) fn audio_thread(
|
||||
_stop: Arc<AtomicBool>,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_channels: u8,
|
||||
_budget: punktfunk_core::audio::AudioBudget,
|
||||
) {
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ pub(super) async fn run(
|
||||
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
cursor_client_draws: Arc<AtomicBool>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
@@ -338,6 +341,27 @@ pub(super) async fn run(
|
||||
None => clip_offer_closed = true,
|
||||
}
|
||||
}
|
||||
retarget = retarget_rx.recv() => {
|
||||
// A pipeline rebuild re-resolved the Automatic rate (see `retarget_tx`). Same
|
||||
// message the `SetBitrate` path answers with — the client's controller treats
|
||||
// any `BitrateChanged` as authoritative for what the encoder now targets, which
|
||||
// is exactly right here: it IS what the encoder now targets, we just weren't
|
||||
// asked. PyroWave reaches this too, and should: its rate is pinned against
|
||||
// mid-stream RETARGETS, but a mode switch legitimately re-resolves the pin
|
||||
// (~1.6 bpp for the new pixel rate) and the client's live-rate display is
|
||||
// otherwise stuck on the old one. Its controller is off, so nothing acts on it.
|
||||
let Some(kbps) = retarget else { break }; // data plane gone
|
||||
tracing::info!(
|
||||
kbps,
|
||||
"encoder re-targeted by a pipeline rebuild — telling the client"
|
||||
);
|
||||
if io::write_msg(&mut ctrl_send, &BitrateChanged { bitrate_kbps: kbps }.encode())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
correction = reconfig_result_rx.recv() => {
|
||||
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||
|
||||
@@ -24,6 +24,63 @@ use super::*;
|
||||
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
|
||||
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
|
||||
/// from it, and the session wiring reads that bit back.
|
||||
/// THE single audio-plane decision for a session: the encode tier AND whether the redundant
|
||||
/// `0xD2` plane is sent. The Welcome's `HOST_CAP_AUDIO_RED` bit is computed from it, and
|
||||
/// `serve_session` reads that bit back to configure the audio thread — so the wire the client is
|
||||
/// promised and the wire we send cannot disagree.
|
||||
///
|
||||
/// Capable-and-agreed for redundancy: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a
|
||||
/// session with an older client keeps the plain `0xC9` wire byte-for-byte.
|
||||
///
|
||||
/// **Both halves are then BUDGETED against the session's video bitrate**
|
||||
/// ([`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget)). Tier `High` and redundancy
|
||||
/// were introduced separately, each costed as "~1 % of the video budget", and they multiply:
|
||||
/// 256 kbps stereo sent twice is 512 kbps — ~10 % of a 5 Mbps session. Audio rides QUIC datagrams,
|
||||
/// outside the ABR loop, so ABR can neither see that nor reclaim it. The budget is what stops a
|
||||
/// constrained link silently handing a tenth of its bandwidth to audio.
|
||||
///
|
||||
/// The operator's `audio.quality` / `audio.redundancy` settings are the REQUEST; the budget may
|
||||
/// lower them, never raise them.
|
||||
///
|
||||
/// NB the plan's "only while the link is actually losing packets" gate is deliberately not here:
|
||||
/// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would
|
||||
/// have to re-derive which plane it is on from every datagram. Deciding once, at handshake, against
|
||||
/// a bitrate we already know is both cheaper and more predictable.
|
||||
/// `wants_redundancy` is the caller's answer to "is `0xD2` even on the table" — at handshake that
|
||||
/// is the client's cap AND the operator's setting; afterwards it is the GRANTED
|
||||
/// `HOST_CAP_AUDIO_RED` bit, so the audio thread re-derives the same rung of the same ladder.
|
||||
pub(super) fn audio_budget(
|
||||
wants_redundancy: bool,
|
||||
video_kbps: u32,
|
||||
channels: u8,
|
||||
) -> punktfunk_core::audio::AudioBudget {
|
||||
let configured = pf_host_config::config().audio_quality.as_deref();
|
||||
let requested = match configured {
|
||||
None => punktfunk_core::audio::AudioTier::default(),
|
||||
Some(s) => punktfunk_core::audio::AudioTier::parse(s).unwrap_or_else(|| {
|
||||
// Once per process: this runs per session, and an operator with a typo in host.env
|
||||
// does not need it on every connect. Never silently downgrade someone's audio.
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
WARNED.call_once(|| {
|
||||
tracing::warn!(
|
||||
value = %s,
|
||||
"audio.quality (PUNKTFUNK_AUDIO_QUALITY) is not one of low/standard/high — \
|
||||
using the default"
|
||||
);
|
||||
});
|
||||
punktfunk_core::audio::AudioTier::default()
|
||||
}),
|
||||
};
|
||||
punktfunk_core::audio::plan_audio_budget(video_kbps, channels, requested, wants_redundancy)
|
||||
}
|
||||
|
||||
/// The operator's answer to "may this session use redundancy at all", before the budget is
|
||||
/// consulted: the client must be able to decode it and the operator must not have forced it off.
|
||||
pub(super) fn redundancy_offered(client_caps: u8) -> bool {
|
||||
client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED != 0
|
||||
&& pf_host_config::config().audio_redundancy.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(super) fn cursor_forward(
|
||||
client_caps: u8,
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
@@ -491,7 +548,12 @@ pub(super) async fn negotiate(
|
||||
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
|
||||
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
|
||||
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
||||
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
|
||||
// budget learned from a prior session whose QUIC MTU discovery settled below the
|
||||
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
|
||||
// shape — small flows pass, the stream is an endless black screen), then this family
|
||||
// default. Healthy paths take the default branch and are byte-identical to before.
|
||||
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt,
|
||||
@@ -564,6 +626,20 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_PEN
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Redundant desktop-audio plane (0xD2): the client asked, the operator has not forced
|
||||
// it off, AND it fits the session's audio budget. Capable-and-agreed like the cursor
|
||||
// bit — a client that did not ask keeps the plain 0xC9 wire byte-for-byte.
|
||||
| if audio_budget(
|
||||
redundancy_offered(hello.client_caps),
|
||||
bitrate_kbps,
|
||||
audio_channels,
|
||||
)
|
||||
.redundancy
|
||||
{
|
||||
punktfunk_core::quic::HOST_CAP_AUDIO_RED
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
@@ -658,6 +734,10 @@ pub(super) async fn negotiate(
|
||||
let start =
|
||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||
bringup.mark("start");
|
||||
// The session is real: watch this connection's MTU discovery settle and turn it into a
|
||||
// path verdict (WARN + learned clamp for the next session on a constrained path; clears a
|
||||
// stale clamp on a healthy one). Bounded ~10 s task, ends by itself.
|
||||
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize);
|
||||
Ok::<_, anyhow::Error>((
|
||||
hello,
|
||||
welcome,
|
||||
|
||||
@@ -629,6 +629,20 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any
|
||||
/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire
|
||||
/// index inherits nothing that could buzz the new device.
|
||||
///
|
||||
/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the
|
||||
/// life of the connection because the client gates on it with a wrapping half-space compare and
|
||||
/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is
|
||||
/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`].
|
||||
fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) {
|
||||
*state = (0, 0);
|
||||
*seen = false;
|
||||
*stop_burst = 0;
|
||||
}
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
@@ -824,11 +838,22 @@ pub(super) fn input_thread(
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
// stale rumble lease (a lease still ticking would buzz the new pad).
|
||||
//
|
||||
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
|
||||
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
|
||||
// and has no reset path, so restarting this counter strands every later
|
||||
// envelope for the re-plugged pad until the host climbs back past the
|
||||
// value the client already stored (up to 128 sends ≈ 15 s of continuous
|
||||
// rumble, or dozens of separate rumble events). The three clears below are
|
||||
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
|
||||
// value across a removal for exactly the same reason (see the comment at
|
||||
// the top of this arm).
|
||||
clear_pad_feedback(
|
||||
&mut rumble_state[idx],
|
||||
&mut rumble_seen[idx],
|
||||
&mut rumble_stop_burst[idx],
|
||||
);
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
@@ -1071,6 +1096,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad re-plug must not strand the client's rumble reorder gate.
|
||||
///
|
||||
/// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path
|
||||
/// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter
|
||||
/// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do
|
||||
/// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the
|
||||
/// counter climbed back past the value the client had already stored — up to 128 sends.
|
||||
///
|
||||
/// Drives the real wire encoder and the real gate, so it fails if either side's rule moves.
|
||||
#[test]
|
||||
fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() {
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2};
|
||||
|
||||
// The client half: one per-pad slot, per connection, never reset.
|
||||
let deliver = |seq: u8, gate: &mut Option<u8>| {
|
||||
let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400);
|
||||
let env = decode_rumble_envelope(&d)
|
||||
.expect("v2 envelope decodes")
|
||||
.envelope
|
||||
.expect("v2 tail present");
|
||||
if GamepadSnapshot::seq_newer(env.seq, *gate) {
|
||||
*gate = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// The host half: one wrapping counter, bumped on every change and every renewal.
|
||||
let mut gate: Option<u8> = None;
|
||||
let mut seq = 0u8;
|
||||
|
||||
// A long rumble before the unplug pushes the client's stored seq well past zero.
|
||||
for _ in 0..100 {
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(deliver(seq, &mut gate));
|
||||
}
|
||||
assert_eq!(gate, Some(100));
|
||||
|
||||
// The pad is unplugged mid-buzz: the lease is cleared, the counter is not.
|
||||
let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST);
|
||||
clear_pad_feedback(&mut state, &mut seen, &mut burst);
|
||||
assert_eq!(
|
||||
(state, seen, burst),
|
||||
((0, 0), false, 0),
|
||||
"lease not cleared"
|
||||
);
|
||||
|
||||
// It returns on the same wire index and the game rumbles again: the very first envelope
|
||||
// has to reach the actuator.
|
||||
seq = seq.wrapping_add(1);
|
||||
assert!(
|
||||
deliver(seq, &mut gate),
|
||||
"first envelope after a re-plug was dropped by the client's reorder gate"
|
||||
);
|
||||
|
||||
// Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and
|
||||
// stays rejected for the whole forward window — this is the bug, reproduced.
|
||||
let mut stranded = Some(100u8);
|
||||
assert!(
|
||||
(1..=100).all(|s| !deliver(s, &mut stranded)),
|
||||
"test is vacuous — a restarted counter should have been gated out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
|
||||
#[test]
|
||||
fn gamepad_accumulator() {
|
||||
|
||||
@@ -1214,6 +1214,9 @@ pub(super) struct SessionContext {
|
||||
/// `Reconfigured { accepted: true, mode: <actually live> }` when a rebuild failed (stayed at
|
||||
/// the old mode) or the backend honored a different refresh than requested.
|
||||
pub(super) reconfig_result_tx: tokio::sync::mpsc::UnboundedSender<Reconfigured>,
|
||||
/// Host-initiated bitrate re-target → control task → the client's `BitrateChanged`. Fired
|
||||
/// by [`adopt_built_bitrate`] when a rebuild lands on a rate the client wasn't told about.
|
||||
pub(super) retarget_tx: tokio::sync::mpsc::UnboundedSender<u32>,
|
||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||
pub(super) fec_target: Arc<AtomicU8>,
|
||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||
@@ -1397,6 +1400,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
retarget_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
timing_conn,
|
||||
@@ -1597,7 +1601,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
) = pipe;
|
||||
// The encoder may have opened at a re-resolved rate (a mirrored head delivering a size this
|
||||
// session never negotiated). Adopt it before anything downstream reads `bitrate_kbps`.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, built_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
built_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
|
||||
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||
@@ -1815,6 +1824,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
|
||||
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
|
||||
let mut phase_ctl = PhaseController::new();
|
||||
// Frame-driven wire-rate cap (see [`PaceBudget`]): a loop local like `phase_ctl`, and for the
|
||||
// same reason — it must survive every in-loop rebuild path so a mid-stream rebuild can't
|
||||
// reopen the overshoot. Bounded burst (CAP) is all a rebuild gap can buy.
|
||||
let mut pace = PaceBudget::new(std::time::Instant::now());
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
@@ -2033,7 +2046,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The new compositor may deliver a different size than the old one did (a
|
||||
// Game→Desktop switch onto a mirrored 4K panel is exactly that), so adopt
|
||||
// the rate the rebuilt encoder actually opened at.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
new_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
vd = new_vd;
|
||||
compositor = sw.compositor;
|
||||
next = std::time::Instant::now();
|
||||
@@ -2173,7 +2191,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
};
|
||||
if rebuilt {
|
||||
adopt_built_bitrate(&mut bitrate_kbps, built_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
built_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||
@@ -2302,6 +2325,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
// The control task already acked the client with its own resolve, which was
|
||||
// higher than what the encoder took. Correct it, or the controller climbs
|
||||
// from a rate the encoder never ran at until its NEXT request happens to be
|
||||
// pre-clamped by the ceiling we just stored.
|
||||
let _ = retarget_tx.send(applied_kbps);
|
||||
}
|
||||
if applied_kbps < bitrate_kbps {
|
||||
// Down-step: the behind-cadence backlog was scored against the old,
|
||||
@@ -2352,6 +2380,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
enc = new_enc;
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
// As in the in-place arm: the ack the client already has promises
|
||||
// more than the fresh encoder accepted — correct it.
|
||||
let _ = retarget_tx.send(applied_kbps);
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
@@ -2793,7 +2824,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// A capture-loss rebuild can land on a different source than it lost (this loop
|
||||
// re-detects the session every cycle, precisely so it can follow a switch), so the
|
||||
// delivered size — and with it an Automatic rate — may have changed under us.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate, &retarget_tx);
|
||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||
last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild
|
||||
next = std::time::Instant::now();
|
||||
@@ -3117,8 +3148,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
|
||||
// Frame-driven mode (T1.1) re-anchors to the ACTUAL submit — arrivals are the clock, and a
|
||||
// fixed `+= interval` grid would drift against them and squeeze the pacing budget; the
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail).
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail). The rate-cap
|
||||
// charge lives under the same guard as the tail's gate: the legacy tick paces by its grid
|
||||
// alone, and charging it without ever accruing would bank unbounded debt that stalls the
|
||||
// loop if a rebuild later flips the capturer to arrival-wait.
|
||||
next = if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||
pace.charge();
|
||||
std::time::Instant::now() + interval
|
||||
} else {
|
||||
next + interval
|
||||
@@ -3389,11 +3424,22 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
};
|
||||
let escalated = cur_depth > 1 || pipelined_active || deescalating;
|
||||
// Export "encode can't hold cadence" for the control task's climb refusal.
|
||||
// An escalated session stays flagged even with the bucket drained: its climb
|
||||
// headroom is spent, and letting climbs resume would saw against the
|
||||
// An escalated session is held to a stricter standard — ANY net behind-frame
|
||||
// keeps it flagged, where an unescalated one is given the full bucket — because
|
||||
// its climb headroom really is partly spent and a climb would saw against the
|
||||
// escalation and starve the de-escalation clean run below.
|
||||
//
|
||||
// But being escalated cannot flag it BY ITSELF, which is what this used to do.
|
||||
// The client can't tell a transient refusal from an encoder's real ceiling: two
|
||||
// identical short acks latch a cap, so a session that escalated once — the
|
||||
// bucket needs ~20 net misses, which a startup hitch supplies while the ABR is
|
||||
// still in slow start at the 20 Mbps default — got pinned there, and stayed
|
||||
// pinned long after the escalation had bought back the headroom it was for.
|
||||
// Escalating exists precisely so cadence CAN be held; once it is (bucket
|
||||
// drained, every frame on time), refusing climbs is refusing the thing that
|
||||
// worked.
|
||||
cadence_degraded.store(
|
||||
escalated || behind_score >= DEPTH_DEGRADE,
|
||||
encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if deescalating {
|
||||
@@ -3477,9 +3523,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||
// to a full interval — ~half on average), sleep only to the rate floor and then
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor caps the encode
|
||||
// rate at ~1.11× target when the source runs faster (compositor Hz > session fps);
|
||||
// the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor leaves per-gap jitter
|
||||
// headroom; the `pace` budget pins the long-run AVERAGE at the pacing rate — the
|
||||
// floor alone let a source that always has a frame pending (an HZ_MULT-overdriven
|
||||
// display under uncapped content) settle at 0.9-interval spacing, 1.11× the
|
||||
// negotiated rate on the wire, frames the client's panel can only drop. The
|
||||
// +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// client liveness) at 1.5×interval cadence and bounds control-servicing latency.
|
||||
//
|
||||
// Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is
|
||||
@@ -3489,9 +3538,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// period becomes interval + encode (≈158 fps off a 240 Hz source; 360 Hz → ~200).
|
||||
// An async encoder (NVENC) returns from submit in ≈0, so t_cap ≈ post-submit and this
|
||||
// is a no-op for it — which is why H.26x already holds full rate. Arrival-anchoring
|
||||
// lets the synchronous encode overlap the interval; the ≥0.9×interval spacing from
|
||||
// the last grab still caps the rate at ~1.11× target.
|
||||
let earliest = t_cap + interval.mul_f32(0.9);
|
||||
// lets the synchronous encode overlap the interval; the budget, not the floor, is
|
||||
// what bounds the sustained rate.
|
||||
let earliest = std::cmp::max(
|
||||
t_cap + interval.mul_f32(0.9),
|
||||
pace.earliest(std::time::Instant::now(), interval),
|
||||
);
|
||||
if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) {
|
||||
std::thread::sleep(d);
|
||||
}
|
||||
@@ -4001,13 +4053,95 @@ fn pacing_hz(session_hz: u32, achieved_hz: u32) -> u32 {
|
||||
achieved_hz.min(session_hz).max(1)
|
||||
}
|
||||
|
||||
/// Long-run rate limiter for the frame-driven trigger (T1.1): pins the AVERAGE encode rate at the
|
||||
/// pacing rate while the 0.9×interval arrival floor keeps its jitter headroom.
|
||||
///
|
||||
/// The floor alone bounds only each gap, so a source that always has a frame pending — a display
|
||||
/// overdriven by `PUNKTFUNK_VDISPLAY_HZ_MULT`, uncapped content — settles at 0.9-interval spacing:
|
||||
/// 1.11× the negotiated rate on the wire (field report: 132 fps on a 120 fps session, frames the
|
||||
/// client's 120 Hz panel can only drop). Credit accrues at one frame per interval of real elapsed
|
||||
/// time (capped at [`Self::CAP`]) and every submitted frame spends one; a grab may run early only
|
||||
/// against banked credit, so per-gap jitter still passes while the average cannot exceed the
|
||||
/// pacing rate. A source at or below the pacing rate banks credit faster than it spends and is
|
||||
/// never delayed.
|
||||
struct PaceBudget {
|
||||
/// Banked frames, in `[-1.0, CAP]`. Transiently dips below 0 when a grab spent credit it had
|
||||
/// only partly banked; the owed fraction is repaid before the next grab.
|
||||
credit: f32,
|
||||
/// When credit last accrued (the previous [`Self::earliest`] call).
|
||||
last: std::time::Instant,
|
||||
}
|
||||
|
||||
impl PaceBudget {
|
||||
/// Burst allowance: at most this many frames may follow a stall back-to-back before the
|
||||
/// bucket re-gates. One frame of instant catch-up plus the floor's own headroom.
|
||||
const CAP: f32 = 1.25;
|
||||
|
||||
fn new(now: std::time::Instant) -> PaceBudget {
|
||||
PaceBudget {
|
||||
credit: Self::CAP,
|
||||
last: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accrue the elapsed credit and return the earliest instant the next grab may run: `now`
|
||||
/// once a full frame is banked, else the missing fraction of an interval out.
|
||||
fn earliest(
|
||||
&mut self,
|
||||
now: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
) -> std::time::Instant {
|
||||
let secs = interval.as_secs_f32();
|
||||
if secs > 0.0 {
|
||||
let accrued = now.duration_since(self.last).as_secs_f32() / secs;
|
||||
self.credit = (self.credit + accrued).min(Self::CAP);
|
||||
}
|
||||
self.last = now;
|
||||
now + interval.mul_f32((1.0 - self.credit).max(0.0))
|
||||
}
|
||||
|
||||
/// One frame submitted — spend its credit.
|
||||
fn charge(&mut self) {
|
||||
self.credit -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the encoder currently fail to hold the frame cadence? Exported to the control task, which
|
||||
/// refuses bitrate CLIMBS while it is true (descents always pass — they are the cure).
|
||||
///
|
||||
/// `escalated` = the session has already spent an adaptive-depth / pipelined-retrieve step to buy
|
||||
/// headroom; `behind_score` is the leaky bucket of frames whose work overran the cadence deadline.
|
||||
/// An escalated session is judged strictly — ANY net behind-frame keeps it flagged — but being
|
||||
/// escalated does not flag it on its own. That distinction is the whole point: the client cannot
|
||||
/// tell a transient refusal from an encoder's hard ceiling (two identical short acks latch a cap),
|
||||
/// so "escalated ⇒ degraded, permanently" pinned Automatic sessions at whatever rate they happened
|
||||
/// to hold when a startup hitch escalated them — routinely the 20 Mbps default, while slow start
|
||||
/// had barely begun. Escalation exists so cadence CAN be held; once it is, refusing climbs refuses
|
||||
/// the thing that worked.
|
||||
fn encode_behind_cadence(escalated: bool, behind_score: u32, degrade_at: u32) -> bool {
|
||||
behind_score >= degrade_at || (escalated && behind_score > 0)
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
/// climb base, the console's sample, what a `SetBitrate` ack is measured against — so letting it
|
||||
/// disagree with the live encoder means each of those reasons about a stream that doesn't exist.
|
||||
/// Silent when nothing changed, which is the overwhelmingly common case.
|
||||
fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc<AtomicU32>) {
|
||||
///
|
||||
/// The client keeps its OWN copy of that number, and it used to move only on an ack — so a
|
||||
/// rebuild that re-resolved an Automatic rate (`build_pipeline` does, whenever the source
|
||||
/// delivers a size the session did not negotiate) left the two disagreeing for the rest of the
|
||||
/// session. The ABR's next climb then computed from the stale base and asked for a rate BELOW
|
||||
/// what the host was already sending: a re-target downward, paying an encoder rebuild to get
|
||||
/// there. So tell the client too — `BitrateChanged` is the same message the `SetBitrate` path
|
||||
/// answers with, and means the same thing arriving unprompted.
|
||||
fn adopt_built_bitrate(
|
||||
current: &mut u32,
|
||||
built: u32,
|
||||
live: &Arc<AtomicU32>,
|
||||
retarget: &tokio::sync::mpsc::UnboundedSender<u32>,
|
||||
) {
|
||||
if built == *current {
|
||||
return;
|
||||
}
|
||||
@@ -4018,6 +4152,7 @@ fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc<AtomicU32>) {
|
||||
);
|
||||
*current = built;
|
||||
live.store(built, Ordering::Relaxed);
|
||||
let _ = retarget.send(built); // control task gone ⇒ the session is ending anyway
|
||||
}
|
||||
|
||||
/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and
|
||||
@@ -4122,9 +4257,19 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
//
|
||||
// KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative
|
||||
// "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow
|
||||
// outlives every in-game/Big Picture hide (0.22.0 field report). Derived from the backend
|
||||
// (correct for pooled reuse too — a kept display only matches its own backend).
|
||||
let cursor_id0_hides = vd.name() == pf_vdisplay::Compositor::Kwin.id();
|
||||
let mut capturer = crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
plan.output_format(),
|
||||
plan.capture,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||
@@ -4252,6 +4397,38 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
// Not escalated: the full bucket is allowed before climbs are refused.
|
||||
assert!(!encode_behind_cadence(false, 0, DEGRADE));
|
||||
assert!(!encode_behind_cadence(false, 9, DEGRADE));
|
||||
assert!(encode_behind_cadence(false, 10, DEGRADE));
|
||||
// Escalated and still missing deadlines: strict — one net behind-frame is enough.
|
||||
assert!(encode_behind_cadence(true, 1, DEGRADE));
|
||||
// Escalated, bucket fully drained: cadence is being HELD, which is what escalating was
|
||||
// for. This is the case that used to stay latched for the rest of the session and pin an
|
||||
// Automatic client at its slow-start rate.
|
||||
assert!(!encode_behind_cadence(true, 0, DEGRADE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_a_rebuilt_rate_tells_the_client() {
|
||||
let live = Arc::new(AtomicU32::new(20_000));
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<u32>();
|
||||
let mut current = 20_000;
|
||||
// The overwhelmingly common case: the rebuild landed on the same rate — silent.
|
||||
adopt_built_bitrate(&mut current, 20_000, &live, &tx);
|
||||
assert_eq!(rx.try_recv().ok(), None);
|
||||
// A re-resolve (the client asked 1080p, the source delivers a mirrored 4K panel): the
|
||||
// host's rate moves, so the client has to hear about it — its controller's climb base is
|
||||
// its own copy of this number, and a stale one makes the next "climb" a cut.
|
||||
adopt_built_bitrate(&mut current, 60_000, &live, &tx);
|
||||
assert_eq!(current, 60_000);
|
||||
assert_eq!(live.load(Ordering::Relaxed), 60_000);
|
||||
assert_eq!(rx.try_recv().ok(), Some(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_never_exceeds_the_session_rate_or_the_display() {
|
||||
// Backend honored the request exactly (the multiplier off): pace at it.
|
||||
@@ -4270,6 +4447,105 @@ mod tests {
|
||||
assert_eq!(pacing_hz(60, 0), 1);
|
||||
}
|
||||
|
||||
/// Drive [`PaceBudget`] against a source that ALWAYS has a frame pending (the overdriven
|
||||
/// display + uncapped content case): each cycle grabs the instant the gate opens, the next
|
||||
/// `earliest` call runs right after (encode folded into the wait, like the loop). Returns the
|
||||
/// grab instants.
|
||||
fn grab_saturated(
|
||||
b: &mut PaceBudget,
|
||||
start: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
n: usize,
|
||||
) -> Vec<std::time::Instant> {
|
||||
let mut now = start;
|
||||
let mut grabs = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let gate = b.earliest(now, interval);
|
||||
let grab = gate.max(now);
|
||||
b.charge();
|
||||
grabs.push(grab);
|
||||
now = grab;
|
||||
}
|
||||
grabs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_pins_a_saturated_source_at_the_interval() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 120);
|
||||
// Whatever the initial credit bought, the total may exceed the on-rate schedule by at
|
||||
// most the burst cap — 120 grabs span no less than (120 - 1 - CAP) intervals.
|
||||
let span = grabs[119].duration_since(grabs[0]);
|
||||
assert!(
|
||||
span >= interval.mul_f32(120.0 - 1.0 - PaceBudget::CAP),
|
||||
"span {span:?} admits more than CAP frames of overshoot"
|
||||
);
|
||||
// And the steady state is EXACTLY the interval: past the warmup, consecutive grabs are
|
||||
// one interval apart (not 0.9 — the 132-fps bug).
|
||||
for w in grabs[20..].windows(2) {
|
||||
let gap = w[1].duration_since(w[0]);
|
||||
assert!(
|
||||
gap >= interval.mul_f32(0.999) && gap <= interval.mul_f32(1.001),
|
||||
"steady-state gap {gap:?} != interval {interval:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_never_delays_an_on_rate_or_slow_source() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// A source at half the pacing rate (a 60 fps game on a 120 fps session): every arrival
|
||||
// banks two frames of credit and spends one — the gate is always already open.
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval * 2;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"slow source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
// Exactly on-rate: still never gated (credit hovers at the cap, never below 1).
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"on-rate source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_burst_after_a_stall_is_capped() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// Settle into the gated steady state, then stall the source for 10 intervals.
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 20);
|
||||
let stall_end = grabs[19] + interval * 10;
|
||||
// However long the stall, the recovery may run ahead of the on-rate schedule by at most
|
||||
// CAP frames: the second post-stall grab is already re-gated.
|
||||
let after = grab_saturated(&mut b, stall_end, interval, 3);
|
||||
assert_eq!(after[0], stall_end, "first post-stall grab is immediate");
|
||||
assert!(
|
||||
after[1].duration_since(after[0]) >= interval.mul_f32(2.0 - PaceBudget::CAP),
|
||||
"second post-stall grab spent more than the burst cap"
|
||||
);
|
||||
assert!(
|
||||
after[2].duration_since(after[1]) >= interval.mul_f32(0.999),
|
||||
"third post-stall grab must be back on the interval grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_mode_multiplier_scales_only_the_refresh() {
|
||||
// Default (no env set in the test process) is 1× — the identity, which is what every
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user