Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e28160ebb3 | |||
| 0b7e4a00ee | |||
| ab6790ef6c | |||
| 52856d4b6b | |||
| 6bbd2a9ed6 | |||
| ade4266336 | |||
| dc834ea478 | |||
| 4516bd48e1 | |||
| 4c9c7e606e | |||
| b12c7d3deb | |||
| 8a4b0c7bae | |||
| d490397908 | |||
| a41f679516 | |||
| e6d9454251 | |||
| 9ca672d434 | |||
| b8fd652cb8 | |||
| 5042ffd935 | |||
| 8bef4e27f9 | |||
| b664ae373d |
@@ -34,3 +34,7 @@ __pycache__/
|
||||
|
||||
# Claude Code project instructions — local to each dev box, not part of the repo.
|
||||
CLAUDE.md
|
||||
|
||||
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
|
||||
.flatpak-repo/
|
||||
.flatpak-build/
|
||||
|
||||
Generated
+14
-14
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2767,7 +2767,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2807,7 +2807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3014,7 +3014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3030,7 +3030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3044,7 +3044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3067,7 +3067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3098,7 +3098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3170,7 +3170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3184,7 +3184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.8.3"
|
||||
"version": "0.8.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -185,8 +185,10 @@ fun ConnectScreen(
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
// appears in the saved-hosts list.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||
// fallback) instead of the error status line — discovery is already restarted when it runs, so
|
||||
// the wait can observe the host reappear.
|
||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
|
||||
val id = identity ?: run {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
@@ -206,18 +208,25 @@ fun ConnectScreen(
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
status = ""
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
|
||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
||||
// already seen live) dial straight through.
|
||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
|
||||
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||
// seen live) dial straight through.
|
||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||
if (identity == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
@@ -232,23 +241,28 @@ fun ConnectScreen(
|
||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
||||
// point at the live one, then dial there.
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||
waker.start(
|
||||
hostName = name,
|
||||
connectsAfter = true,
|
||||
macs = macs,
|
||||
lastIp = targetHost,
|
||||
isOnline = { liveAdvert() != null },
|
||||
onOnline = {
|
||||
val live = liveAdvert()
|
||||
// Woke back on a new address? Re-key the saved record so it (and future
|
||||
// connects) point at the live one, then dial there (no fallback on this
|
||||
// redial — a second failure surfaces as the plain error).
|
||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
||||
}
|
||||
|
||||
@@ -346,11 +346,12 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
|
||||
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
|
||||
toggle(
|
||||
choice(
|
||||
"hud", "Interface", "Statistics overlay",
|
||||
"Show FPS, throughput and latency while streaming.",
|
||||
s.statsHudEnabled,
|
||||
) { update(s.copy(statsHudEnabled = it)) },
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"library", null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
|
||||
@@ -30,8 +30,13 @@ data class Settings(
|
||||
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||
val codec: String = "auto",
|
||||
val micEnabled: Boolean = false,
|
||||
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */
|
||||
val statsHudEnabled: Boolean = true,
|
||||
/**
|
||||
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
|
||||
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
|
||||
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
|
||||
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
|
||||
*/
|
||||
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
|
||||
/**
|
||||
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
|
||||
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
|
||||
@@ -81,6 +86,27 @@ data class Settings(
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
||||
|
||||
/**
|
||||
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
|
||||
* of the previous one, so toning down never hides a number a lower tier keeps:
|
||||
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
|
||||
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
|
||||
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (lost / skipped / FEC) when nonzero. The default.
|
||||
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
|
||||
* `host+network + decode` stage equation.
|
||||
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
|
||||
*/
|
||||
enum class StatsVerbosity(val label: String) {
|
||||
OFF("Off"),
|
||||
COMPACT("Compact"),
|
||||
NORMAL("Normal"),
|
||||
DETAILED("Detailed");
|
||||
|
||||
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
|
||||
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
|
||||
}
|
||||
|
||||
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||
class SettingsStore(context: Context) {
|
||||
private val prefs =
|
||||
@@ -97,7 +123,16 @@ class SettingsStore(context: Context) {
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
statsHudEnabled = prefs.getBoolean(K_HUD, true),
|
||||
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
|
||||
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
|
||||
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
|
||||
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
|
||||
// down to the new default, which is the whole point of adding tiers.
|
||||
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
|
||||
StatsVerbosity.OFF
|
||||
} else {
|
||||
StatsVerbosity.NORMAL
|
||||
},
|
||||
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
@@ -120,7 +155,7 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
@@ -140,6 +175,10 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
const val K_STATS_VERBOSITY = "stats_verbosity"
|
||||
|
||||
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
|
||||
* written. */
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
@@ -269,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
|
||||
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
checked = s.autoWakeEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Stats overlay",
|
||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||
checked = s.statsHudEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
SettingDropdown(
|
||||
label = "Stats overlay",
|
||||
options = STATS_VERBOSITY_OPTIONS,
|
||||
selected = s.statsVerbosity,
|
||||
) { v -> update(s.copy(statsVerbosity = v)) }
|
||||
Text(
|
||||
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
|
||||
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
|
||||
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,61 +16,63 @@ import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
|
||||
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 22-double layout from
|
||||
* [NativeBridge.nativeVideoStats]:
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
||||
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
|
||||
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
|
||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
||||
* older layouts just omit those lines.
|
||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||
* fec, frames]`.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
||||
if (s.size < 10) return
|
||||
internal fun StatsOverlay(
|
||||
s: DoubleArray,
|
||||
verbosity: StatsVerbosity,
|
||||
decoderLabel: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||
val w = s[6].toInt()
|
||||
val h = s[7].toInt()
|
||||
val hz = s[8].toInt()
|
||||
val latValid = s[4] != 0.0
|
||||
val skew = s[5] != 0.0
|
||||
val lost = s[9].toLong()
|
||||
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (decoderLabel.isNotEmpty()) {
|
||||
Text(
|
||||
decoderLabel,
|
||||
color = Color(0xFFB0D0FF),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
// Compact: everything the glance-value needs on one line, nothing else.
|
||||
if (verbosity == StatsVerbosity.COMPACT) {
|
||||
statLine(compactLine(s, latValid), Color.White)
|
||||
return@Column
|
||||
}
|
||||
videoFeedLine(s)?.let { feed ->
|
||||
Text(
|
||||
feed,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
|
||||
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||
if (detailed && decoderLabel.isNotEmpty()) {
|
||||
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||
}
|
||||
if (detailed) {
|
||||
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||
}
|
||||
if (latValid) {
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
Text(
|
||||
statLine(
|
||||
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
Color.White,
|
||||
)
|
||||
if (s.size >= 16) {
|
||||
if (detailed && s.size >= 16) {
|
||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
||||
// reported its share this window; otherwise the combined term (old host / no
|
||||
// matched 0xCF timing).
|
||||
@@ -79,25 +81,60 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
|
||||
} else {
|
||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||
}
|
||||
Text(
|
||||
equation,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
statLine(equation, Color.White)
|
||||
}
|
||||
}
|
||||
if (lost > 0) {
|
||||
Text(
|
||||
"lost $lost",
|
||||
color = Color(0xFFFFB0B0),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
|
||||
@Composable
|
||||
private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(s[2])} ms")
|
||||
add("${s[1].roundToInt()} Mb/s")
|
||||
}
|
||||
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
|
||||
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
|
||||
return parts.joinToString(" · ") + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the spec's line-4 counters from the per-window doubles at 18–21 —
|
||||
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
|
||||
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
|
||||
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
|
||||
* session-cumulative `lostTotal` so an older native lib still reports loss.
|
||||
*/
|
||||
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
|
||||
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
|
||||
val lost = s[18].toLong()
|
||||
val skipped = s[19].toLong()
|
||||
val fec = s[20].toLong()
|
||||
val frames = s[21].toLong()
|
||||
if (lost == 0L && skipped == 0L && fec == 0L) return null
|
||||
return buildList {
|
||||
if (lost > 0) {
|
||||
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
|
||||
add("lost $lost (${"%.1f".format(pct)}%)")
|
||||
}
|
||||
if (skipped > 0) add("skipped $skipped")
|
||||
if (fec > 0) add("FEC $fec")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||
|
||||
@@ -54,15 +54,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
||||
// comes from Settings.
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
|
||||
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
|
||||
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
|
||||
// blanks the numbers for a poll interval.
|
||||
val initialSettings = remember { SettingsStore(context).load() }
|
||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||
var decoderLabel by remember { mutableStateOf("") }
|
||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
|
||||
val statsOn = statsVerbosity != StatsVerbosity.OFF
|
||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||
val touchMode = initialSettings.touchMode
|
||||
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
||||
@@ -73,9 +77,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
||||
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||
LaunchedEffect(handle, showStats) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
||||
if (showStats) {
|
||||
LaunchedEffect(handle, statsOn) {
|
||||
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||
if (statsOn) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
stats = NativeBridge.nativeVideoStats(handle)
|
||||
@@ -250,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
)
|
||||
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
|
||||
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||
if (showStats) {
|
||||
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
||||
if (statsOn) {
|
||||
stats?.let {
|
||||
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
onToggleStats = { showStats = !showStats },
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
|
||||
*
|
||||
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||
* windows); three-finger tap = [onToggleStats] (the stats HUD).
|
||||
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
|
||||
*/
|
||||
/**
|
||||
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
|
||||
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
trackpad: Boolean,
|
||||
onToggleStats: () -> Unit,
|
||||
onCycleStats: () -> Unit,
|
||||
) {
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||
} else if (!moved) {
|
||||
when {
|
||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
||||
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||
maxFingers == 2 -> { // two-finger tap → right click
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||
|
||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream") { StreamScene() }
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
||||
compositor = 1,
|
||||
gamepad = 2,
|
||||
micEnabled = true,
|
||||
statsHudEnabled = true,
|
||||
statsVerbosity = StatsVerbosity.DETAILED,
|
||||
touchMode = TouchMode.TRACKPAD,
|
||||
),
|
||||
onChange = {},
|
||||
@@ -177,9 +178,12 @@ internal fun PairDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */
|
||||
/**
|
||||
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
|
||||
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamScene() {
|
||||
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -187,17 +191,21 @@ internal fun StreamScene() {
|
||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||
),
|
||||
) {
|
||||
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
|
||||
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
|
||||
// split equation, and the decoder label line shows the ranked low-latency decoder.
|
||||
// The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
|
||||
// 1.3 ms headline so it renders the full split equation, and the decoder label shows the
|
||||
// ranked low-latency decoder. Light per-window loss (lost 2 · skipped 1 · FEC 5 of 238) so
|
||||
// the reliability line (NORMAL/DETAILED) and the compact loss flag both render.
|
||||
StatsOverlay(
|
||||
doubleArrayOf(
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
|
||||
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
|
||||
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
|
||||
2.0, 1.0, 5.0, 238.0,
|
||||
),
|
||||
verbosity = verbosity,
|
||||
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
||||
)
|
||||
|
||||
@@ -493,10 +493,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||
}
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
|
||||
/// loop never inflates the decode stage.
|
||||
struct OutputReady {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
}
|
||||
|
||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
||||
@@ -507,8 +511,12 @@ enum DecodeEvent {
|
||||
Au(Frame),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts).
|
||||
OutputAvailable { index: usize, pts_us: u64 },
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
OutputAvailable {
|
||||
index: usize,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
},
|
||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||
FormatChanged,
|
||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||
@@ -569,6 +577,10 @@ fn run_async(
|
||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||
index: idx,
|
||||
pts_us: info.presentation_time_us().max(0) as u64,
|
||||
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
|
||||
// decode stage ends when the frame actually became available — not after the
|
||||
// channel hop + whatever work the loop coalesces in front of presenting it.
|
||||
decoded_ns: now_realtime_ns(),
|
||||
});
|
||||
})),
|
||||
on_format_changed: Some(Box::new(move |_fmt| {
|
||||
@@ -697,29 +709,30 @@ fn run_async(
|
||||
};
|
||||
let work_t0 = Instant::now();
|
||||
let mut fmt_dirty = false;
|
||||
let mut au_dropped = false;
|
||||
let mut aus_dropped: u64 = 0;
|
||||
if let Some(ev) = ev0 {
|
||||
au_dropped |= dispatch_event(
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
);
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
// presentation across a decode burst, and batched feeding.
|
||||
while let Ok(ev) = ev_rx.try_recv() {
|
||||
au_dropped |= dispatch_event(
|
||||
aus_dropped += u64::from(dispatch_event(
|
||||
ev,
|
||||
&mut pending_aus,
|
||||
&mut free_inputs,
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
);
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
if fmt_dirty {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
@@ -768,7 +781,7 @@ fn run_async(
|
||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||
// control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped || au_dropped {
|
||||
if dropped > last_dropped || aus_dropped > 0 {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
@@ -863,7 +876,15 @@ fn dispatch_event(
|
||||
}
|
||||
}
|
||||
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
|
||||
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
|
||||
DecodeEvent::OutputAvailable {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
} => ready.push(OutputReady {
|
||||
index,
|
||||
pts_us,
|
||||
decoded_ns,
|
||||
}),
|
||||
DecodeEvent::FormatChanged => *fmt_dirty = true,
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
@@ -935,15 +956,19 @@ fn present_ready(
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for o in ready.iter() {
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
}
|
||||
}
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let render = i == last;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => *rendered += 1,
|
||||
Ok(()) => *discarded += 1,
|
||||
Ok(()) => {
|
||||
*discarded += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
||||
@@ -952,6 +977,7 @@ fn present_ready(
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
@@ -1143,6 +1169,7 @@ fn drain(
|
||||
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||
}
|
||||
discarded += 1;
|
||||
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||
}
|
||||
}
|
||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||
@@ -1203,18 +1230,20 @@ fn note_decoded(
|
||||
in_flight,
|
||||
clock_offset,
|
||||
buf.info().presentation_time_us().max(0) as u64,
|
||||
now_realtime_ns(), // sync loop: the dequeue IS the availability instant
|
||||
);
|
||||
}
|
||||
|
||||
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
|
||||
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
|
||||
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls
|
||||
/// this with the `decoded` stamp taken in the output callback itself (the availability instant).
|
||||
fn note_decoded_pts(
|
||||
stats: &crate::stats::VideoStats,
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
pts_us: u64,
|
||||
decoded_ns: i128,
|
||||
) {
|
||||
let decoded_ns = now_realtime_ns();
|
||||
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||
let mut received_ns = None;
|
||||
while let Some(&(p, r)) = in_flight.front() {
|
||||
|
||||
@@ -144,16 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
|
||||
/// (unified stats spec, `design/stats-unification.md`). Returns 22 doubles
|
||||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||
/// netP50Ms]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
||||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
|
||||
/// (the two flags are 1.0/0.0; indexes 0–17 match the previous 18-double layout — 0–13 the original
|
||||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||||
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call
|
||||
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||||
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||||
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||||
/// the cumulative session total for older readers), or `null` when no decode thread is running.
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
if h.video.lock().unwrap().is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
}
|
||||
let snap = h.stats.drain();
|
||||
let snap = h
|
||||
.stats
|
||||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||||
let mode = h.client.mode();
|
||||
let color = h.client.color;
|
||||
let buf: [f64; 18] = [
|
||||
let buf: [f64; 22] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.e2e_p50_ms,
|
||||
@@ -200,6 +206,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||
snap.host_p50_ms,
|
||||
snap.net_p50_ms,
|
||||
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||||
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||||
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||||
snap.lost as f64,
|
||||
snap.skipped as f64,
|
||||
snap.fec as f64,
|
||||
snap.frames as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stats.set_enabled(enabled != 0);
|
||||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||||
h.stats.set_enabled(
|
||||
enabled != 0,
|
||||
h.client.frames_dropped(),
|
||||
h.client.fec_recovered_shards(),
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
|
||||
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
|
||||
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
|
||||
//! host emits none and the combined term stands. The decode thread is the sole writer
|
||||
//! host emits none and the combined term stands. The spec's line-4 counters are per-window too:
|
||||
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
|
||||
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
|
||||
//! (`note_skipped`). The decode thread is the sole writer
|
||||
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
|
||||
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
|
||||
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||
@@ -54,6 +57,14 @@ struct Inner {
|
||||
net_us: Vec<u64>,
|
||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||
decode_us: Vec<u64>,
|
||||
/// Client-side newest-wins/pacing drops this window (decoded frames released without
|
||||
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
|
||||
skipped: u64,
|
||||
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
|
||||
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
|
||||
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
|
||||
last_dropped_total: u64,
|
||||
last_fec_total: u64,
|
||||
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||
skew_corrected: bool,
|
||||
}
|
||||
@@ -76,6 +87,16 @@ pub struct Snapshot {
|
||||
pub net_p50_ms: f64,
|
||||
pub lat_valid: bool,
|
||||
pub skew_corrected: bool,
|
||||
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
|
||||
/// spec's loss percentage `lost / (received + lost)` exactly.
|
||||
pub frames: u64,
|
||||
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
|
||||
/// session-cumulative connector counter).
|
||||
pub lost: u64,
|
||||
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
|
||||
pub skipped: u64,
|
||||
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
|
||||
pub fec: u64,
|
||||
}
|
||||
|
||||
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
|
||||
@@ -101,6 +122,9 @@ impl VideoStats {
|
||||
host_us: Vec::with_capacity(256),
|
||||
net_us: Vec::with_capacity(256),
|
||||
decode_us: Vec::with_capacity(256),
|
||||
skipped: 0,
|
||||
last_dropped_total: 0,
|
||||
last_fec_total: 0,
|
||||
skew_corrected: false,
|
||||
}),
|
||||
}
|
||||
@@ -115,8 +139,10 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
|
||||
/// in counters (or a window start) from before the overlay was visible.
|
||||
pub fn set_enabled(&self, on: bool) {
|
||||
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
|
||||
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
|
||||
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
|
||||
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||
if on && !was {
|
||||
let mut g = self
|
||||
@@ -131,6 +157,9 @@ impl VideoStats {
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +235,22 @@ impl VideoStats {
|
||||
g.net_us.push(net_us);
|
||||
}
|
||||
|
||||
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
|
||||
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
|
||||
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn note_skipped(&self, n: u64) {
|
||||
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
|
||||
return; // HUD hidden — skip the lock
|
||||
}
|
||||
// Poison-proof for the same reason as `note_received`.
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.skipped += n;
|
||||
}
|
||||
|
||||
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
|
||||
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||
/// this pts predates the HUD being shown).
|
||||
@@ -229,7 +274,9 @@ impl VideoStats {
|
||||
}
|
||||
|
||||
/// Compute the window's rates + latency percentiles, then reset for the next window.
|
||||
pub fn drain(&self) -> Snapshot {
|
||||
/// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
|
||||
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
|
||||
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
|
||||
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||
// fine.
|
||||
let mut g = self
|
||||
@@ -255,6 +302,10 @@ impl VideoStats {
|
||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||
lat_valid: !g.e2e_us.is_empty(),
|
||||
skew_corrected: g.skew_corrected,
|
||||
frames: g.frames,
|
||||
lost: dropped_total.saturating_sub(g.last_dropped_total),
|
||||
skipped: g.skipped,
|
||||
fec: fec_total.saturating_sub(g.last_fec_total),
|
||||
};
|
||||
g.window_start = Instant::now();
|
||||
g.frames = 0;
|
||||
@@ -264,6 +315,9 @@ impl VideoStats {
|
||||
g.host_us.clear();
|
||||
g.net_us.clear();
|
||||
g.decode_us.clear();
|
||||
g.skipped = 0;
|
||||
g.last_dropped_total = dropped_total;
|
||||
g.last_fec_total = fec_total;
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,14 +422,24 @@ struct ContentView: View {
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||
}
|
||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
||||
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||||
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
|
||||
// (the host log shows no connection attempt at all; the tile pip and this gate share the
|
||||
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
|
||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so we can observe it reappear
|
||||
waker.start(
|
||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||
discovery.start() // so the wake-wait can observe it reappear
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||
onUnreachable: {
|
||||
waker.start(
|
||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||
})
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
@@ -437,10 +447,12 @@ struct ContentView: View {
|
||||
|
||||
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||||
/// is advertising (and is a harmless no-op otherwise).
|
||||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||||
private func startSessionDirect(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||||
onUnreachable: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
prepareWake(for: host)
|
||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||
@@ -461,7 +473,8 @@ struct ContentView: View {
|
||||
preferredCodec: preferredCodecByte,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
requestAccess: requestAccess,
|
||||
onUnreachable: onUnreachable)
|
||||
}
|
||||
|
||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||
|
||||
@@ -135,6 +135,10 @@ final class SessionModel: ObservableObject {
|
||||
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||
/// for the wait; nil = trust-on-first-use.
|
||||
/// `onUnreachable`, when set, replaces the "could not connect" alert for a plain connect
|
||||
/// failure: the caller takes over recovery (the Wake-on-LAN wait for a host that stopped
|
||||
/// advertising). It never fires for the delegated-approval path, whose failure text carries
|
||||
/// its own instructions.
|
||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false,
|
||||
requestAccess: Bool = false) {
|
||||
requestAccess: Bool = false,
|
||||
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||||
guard phase == .idle else { return }
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
|
||||
case .failure:
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
if requestAccess {
|
||||
if let onUnreachable, !requestAccess {
|
||||
// The caller owns recovery (wake-and-retry) — no error alert here; its
|
||||
// own overlay explains what's happening.
|
||||
onUnreachable()
|
||||
} else if requestAccess {
|
||||
// The delegated-approval connect ended without being admitted: the
|
||||
// operator didn't approve it before the host's park window elapsed (or
|
||||
// the host was unreachable).
|
||||
|
||||
@@ -154,6 +154,30 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
|
||||
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
|
||||
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
|
||||
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
|
||||
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
|
||||
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
|
||||
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
|
||||
///
|
||||
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
@@ -164,6 +188,10 @@ mod session_main {
|
||||
)
|
||||
.init();
|
||||
|
||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
enable_radv_video_decode();
|
||||
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
||||
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||
|
||||
@@ -58,6 +58,11 @@ pub struct AppModel {
|
||||
hosts: Controller<HostsPage>,
|
||||
/// One session child at a time — connects while one runs are ignored.
|
||||
busy: bool,
|
||||
/// Armed by [`AppMsg::WakeConnect`] (a dial to a host that isn't advertising but has a
|
||||
/// known MAC): if THAT dial's child exits with a connect failure, `SessionExited` falls
|
||||
/// back into the visible wake-and-wait instead of an error. Consumed on the next exit and
|
||||
/// matched against the exiting request, so it can never redirect an unrelated failure.
|
||||
wake_fallback: Option<ConnectRequest>,
|
||||
/// The request-access "waiting for approval" dialog, closed on the first child
|
||||
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and
|
||||
/// `AppMsg` must stay `Send` for the session child's reader thread.
|
||||
@@ -68,7 +73,9 @@ pub struct AppModel {
|
||||
pub enum AppMsg {
|
||||
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
||||
Connect(ConnectRequest),
|
||||
/// Wake an offline saved host, poll until it advertises, then `Connect`.
|
||||
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
|
||||
/// packet and DIAL IMMEDIATELY (mDNS absence ≠ unreachable — routed/Tailscale hosts
|
||||
/// never advertise here); only a failed dial falls into the visible wake-and-wait.
|
||||
WakeConnect(ConnectRequest),
|
||||
/// The SPAKE2 PIN ceremony dialog.
|
||||
Pair(ConnectRequest),
|
||||
@@ -192,6 +199,7 @@ impl SimpleComponent for AppModel {
|
||||
gamepad: init.gamepad,
|
||||
hosts,
|
||||
busy: false,
|
||||
wake_fallback: None,
|
||||
waiting: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
install_actions(&model.window, &sender);
|
||||
@@ -293,7 +301,15 @@ impl SimpleComponent for AppModel {
|
||||
}
|
||||
AppMsg::WakeConnect(req) => {
|
||||
if !self.busy {
|
||||
crate::ui_trust::wake_and_connect(&self.window, &sender, req);
|
||||
// DIAL FIRST — no mDNS advert does NOT mean unreachable: a host reached over
|
||||
// a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
|
||||
// gating the dial on presence bricked exactly those reconnects. Fire the magic
|
||||
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
|
||||
// box is already booting while the dial times out, arm the wake-wait fallback
|
||||
// for THIS request, and connect immediately.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
sender.input(AppMsg::Connect(req));
|
||||
}
|
||||
}
|
||||
AppMsg::Pair(req) => {
|
||||
@@ -365,11 +381,23 @@ impl SimpleComponent for AppModel {
|
||||
self.close_waiting();
|
||||
self.busy = false;
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
// The dial-first wake fallback (armed by `WakeConnect`, consumed on every exit):
|
||||
// a failed dial to the non-advertising host it was armed for falls into the
|
||||
// visible wake-and-wait instead of an error alert. Matched by fingerprint (else
|
||||
// address) so a stale armed request can never redirect another host's failure.
|
||||
let wake_fb =
|
||||
self.wake_fallback
|
||||
.take()
|
||||
.filter(|fb| match (&fb.fp_hex, &req.fp_hex) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => fb.addr == req.addr && fb.port == req.port,
|
||||
});
|
||||
match (code, error, ended) {
|
||||
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
||||
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
|
||||
(_, Some((_, true)), _) if !tofu => {
|
||||
// The stored pin no longer matches (rotated cert or impostor).
|
||||
// The stored pin no longer matches (rotated cert or impostor). The host
|
||||
// ANSWERED — never the wake fallback.
|
||||
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(
|
||||
&self.window,
|
||||
@@ -378,10 +406,18 @@ impl SimpleComponent for AppModel {
|
||||
req,
|
||||
);
|
||||
}
|
||||
// A fingerprint mismatch means the host ANSWERED — reachable, so the plain
|
||||
// error arms below handle it; only a genuine connect failure wakes.
|
||||
(_, Some((_, false)), _) if wake_fb.is_some() => {
|
||||
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
|
||||
}
|
||||
(_, Some((msg, _)), _) => self
|
||||
.hosts
|
||||
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
|
||||
(-1, None, _) => {} // killed (request-access cancel) — already handled
|
||||
(_, None, _) if wake_fb.is_some() => {
|
||||
crate::ui_trust::wake_and_connect(&self.window, &sender, req)
|
||||
}
|
||||
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
|
||||
"Stream session failed (punktfunk-session exit {code})"
|
||||
))),
|
||||
|
||||
@@ -306,7 +306,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
}
|
||||
overlay.add_controller(right_click);
|
||||
|
||||
// Auto-wake: offline + a known MAC routes to wake-and-wait.
|
||||
// Auto-wake: not advertising + a known MAC routes to WakeConnect, which
|
||||
// dials first (a routed/Tailscale host is mDNS-blind, not asleep) and only
|
||||
// falls into the wake-and-wait when the dial fails.
|
||||
let wake_first = !online && !req.mac.is_empty();
|
||||
let sender = sender.clone();
|
||||
returned.connect_activate(move |_| {
|
||||
|
||||
@@ -11,11 +11,12 @@ use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use relm4::prelude::*;
|
||||
|
||||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet,
|
||||
/// then we poll mDNS until it comes back online — re-sending every few seconds up to a
|
||||
/// timeout — and route back into the trust gate, **re-keying the saved record if the
|
||||
/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the
|
||||
/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||
pub fn wake_and_connect(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
|
||||
@@ -113,7 +113,12 @@ for scene in "${SCENES[@]}"; do
|
||||
"$BIN" >"$WORK/log" 2>&1 &
|
||||
pid=$!
|
||||
ready=0
|
||||
for _ in $(seq 1 200); do # up to ~20s
|
||||
# Up to ~60s: a warm scene signals in ~1-2s, but the FIRST launch pays a large one-time
|
||||
# cold-start under software rendering (llvmpipe GL-shader + fontconfig cache build, plus
|
||||
# first-run client-identity generation into the shared scratch HOME) that can run ~25s on a
|
||||
# loaded CI runner. A 20s cap tripped exactly one scene (the first) even though every scene
|
||||
# rendered fine — give it headroom instead of red-lighting the whole capture on cold start.
|
||||
for _ in $(seq 1 600); do
|
||||
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||
ready=1
|
||||
break
|
||||
|
||||
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
initiate_opts(ctx, target, set_screen, set_status, false)
|
||||
}
|
||||
|
||||
/// Dial-first for a saved host that isn't advertising but has a known MAC: fire the magic packet
|
||||
/// now (fire-and-forget — harmless if it's awake, and a genuinely-asleep box is already booting
|
||||
/// while the dial times out) and dial IMMEDIATELY. mDNS absence does NOT mean unreachable — a
|
||||
/// host reached over a routed network (Tailscale/VPN/another subnet) is mDNS-blind forever, and
|
||||
/// gating the dial on presence bricked exactly those reconnects. Only a failed dial falls into
|
||||
/// the visible [`wake_and_connect`] wait.
|
||||
pub(crate) fn initiate_waking(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
initiate_opts(ctx, target, set_screen, set_status, true)
|
||||
}
|
||||
|
||||
fn initiate_opts(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
wake_on_fail: bool,
|
||||
) {
|
||||
let known = KnownHosts::load();
|
||||
let pin = target
|
||||
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
|
||||
})
|
||||
.and_then(|fp| trust::parse_hex32(&fp));
|
||||
|
||||
let opts = ConnectOpts {
|
||||
wake_on_fail,
|
||||
..ConnectOpts::default()
|
||||
};
|
||||
if let Some(pin) = pin {
|
||||
connect(ctx, &target, Some(pin), set_screen, set_status);
|
||||
connect_with(ctx, &target, Some(pin), set_screen, set_status, opts);
|
||||
} else if target.pair_optional {
|
||||
connect(ctx, &target, None, set_screen, set_status); // TOFU
|
||||
connect_with(ctx, &target, None, set_screen, set_status, opts); // TOFU
|
||||
} else {
|
||||
*ctx.shared.target.lock().unwrap() = target;
|
||||
set_screen.call(Screen::Pair);
|
||||
@@ -141,6 +171,13 @@ pub(crate) struct ConnectOpts {
|
||||
/// silently when the parked connect finally resolves — without touching a screen a new
|
||||
/// session may already own.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
/// Fall into the Wake-on-LAN wait ([`wake_and_connect`]) when THIS dial fails with a plain
|
||||
/// connect failure (not a trust rejection). Set by the dial-first path for a saved host that
|
||||
/// isn't advertising but has a known MAC — the dial is attempted unconditionally (mDNS
|
||||
/// absence ≠ unreachable: routed/Tailscale hosts never advertise here), and only a real
|
||||
/// failure escalates to the visible "Waking…" wait. The wait's own redial clears the flag,
|
||||
/// so it can't loop.
|
||||
wake_on_fail: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectOpts {
|
||||
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
|
||||
persist_paired: false,
|
||||
awaiting_approval: false,
|
||||
cancel: None,
|
||||
wake_on_fail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +248,8 @@ fn connect_with(
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let wake_on_fail = opts.wake_on_fail;
|
||||
let ctx = ctx.clone();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
@@ -264,8 +304,14 @@ fn connect_with(
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
// The host ANSWERED, so this never takes the wake fallback.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else if wake_on_fail {
|
||||
// The dial-first attempt to a non-advertising host failed — it may genuinely
|
||||
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
|
||||
// second failure lands on the host list, not back here).
|
||||
wake_and_connect(&ctx, target.clone(), &ss, &st);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
||||
persist_paired: true,
|
||||
awaiting_approval: true,
|
||||
cancel: Some(cancel),
|
||||
wake_on_fail: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
|
||||
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
|
||||
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
|
||||
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
|
||||
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
|
||||
/// IP); on timeout or Cancel we return to the host list.
|
||||
pub(crate) fn wake_and_connect(
|
||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
|
||||
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
|
||||
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
|
||||
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
|
||||
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
|
||||
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
|
||||
/// the host list.
|
||||
fn wake_and_connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! The Help screen: a short note on the in-stream capture model plus a reference of the keyboard
|
||||
//! shortcuts — reached from the Help button on the host list. The Windows counterpart of the GTK
|
||||
//! client's Keyboard Shortcuts window; the bindings themselves live in [`crate::input`], so both
|
||||
//! clients document the same set.
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
|
||||
/// does. Read-only — the bindings themselves live in the input hook ([`crate::input`]).
|
||||
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("F11", "Toggle fullscreen"),
|
||||
(
|
||||
"Ctrl+Alt+Shift+Q",
|
||||
"Release captured input (click the stream to recapture)",
|
||||
),
|
||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
||||
];
|
||||
|
||||
/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
|
||||
fn key_chip(keys: &str) -> Element {
|
||||
border(text_block(keys).font_size(12.0).semibold())
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_thickness(uniform(1.0))
|
||||
.corner_radius(6.0)
|
||||
.padding(edges(8.0, 3.0, 8.0, 3.0))
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A read-only reference card listing the in-stream keyboard shortcuts. One grid, chord chip then
|
||||
/// action, so the actions line up across rows.
|
||||
fn shortcuts_reference() -> Element {
|
||||
let mut children: Vec<Element> = Vec::new();
|
||||
for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() {
|
||||
let row = i as i32;
|
||||
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
||||
let action_cell: Element = text_block(*action)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into();
|
||||
children.push(action_cell.grid_row(row).grid_column(1));
|
||||
}
|
||||
let table = grid(children)
|
||||
.columns([GridLength::Auto, GridLength::Star(1.0)])
|
||||
.rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()])
|
||||
.column_spacing(12.0)
|
||||
.row_spacing(6.0);
|
||||
card(vstack((
|
||||
text_block("In-stream keyboard shortcuts")
|
||||
.semibold()
|
||||
.margin(edges(0.0, 0.0, 0.0, 8.0)),
|
||||
table,
|
||||
)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The Help screen: a `page`-column with a Back button to the host list, an intro card on the
|
||||
/// capture model, and the shortcuts reference. Hook-free — called inline from `root` like the
|
||||
/// other static screens.
|
||||
pub(crate) fn help_page(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::Hosts)
|
||||
});
|
||||
|
||||
let intro = card(
|
||||
vstack((
|
||||
text_block("During a stream").font_size(15.0).semibold(),
|
||||
text_block(
|
||||
"Click the stream to capture your mouse and keyboard \u{2014} the shortcuts below \
|
||||
then work while you play. Release capture to hand the cursor back to this \
|
||||
computer, and click the stream again to retake it.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
);
|
||||
|
||||
page(vec![
|
||||
page_header("Help", back_btn),
|
||||
intro.into(),
|
||||
shortcuts_reference(),
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
||||
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
|
||||
|
||||
use super::connect::{initiate, wake_and_connect};
|
||||
use super::connect::{initiate, initiate_waking};
|
||||
use super::speed::SpeedState;
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc, Target};
|
||||
@@ -277,6 +277,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
}),
|
||||
button("Help").icon(Symbol::Help).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Help)
|
||||
}),
|
||||
button("Settings").icon(Symbol::Setting).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
@@ -386,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
),
|
||||
Some(menu),
|
||||
Some(Box::new(move || {
|
||||
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
||||
// the network (re-sending periodically) before dialing — a cold box boots far
|
||||
// slower than a connect will sit. An online host dials straight away.
|
||||
// Saved host with a known MAC that isn't advertising: fire a wake packet and
|
||||
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
|
||||
// advertises here); only a failed dial falls into the "Waking…" wait. An
|
||||
// online host dials straight away.
|
||||
if can_wake {
|
||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||
initiate_waking(&ctx2, target.clone(), &ss, &st);
|
||||
} else {
|
||||
initiate(&ctx2, target.clone(), &ss, &st);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
|
||||
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
|
||||
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||
@@ -23,6 +24,7 @@
|
||||
//! present must not go through state/rerender.
|
||||
|
||||
mod connect;
|
||||
mod help;
|
||||
mod hosts;
|
||||
mod licenses;
|
||||
mod pair;
|
||||
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
|
||||
Settings,
|
||||
/// Open-source / third-party license notices (reached from Settings).
|
||||
Licenses,
|
||||
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
|
||||
/// button).
|
||||
Help,
|
||||
Pair,
|
||||
/// Per-host network speed test (probe burst + recommended bitrate).
|
||||
SpeedTest,
|
||||
@@ -382,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
set_hover,
|
||||
},
|
||||
),
|
||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
||||
// no hooks (they never touch `cx`), so calling them inline is sound.
|
||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
|
||||
// help_page use no hooks (they never touch `cx`), so calling them inline is sound.
|
||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||
Screen::Waking => connect::waking_page(ctx, &set_screen),
|
||||
@@ -395,6 +400,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&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 }),
|
||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
||||
|
||||
@@ -109,59 +109,6 @@ fn settings_card(controls: Vec<Element>) -> Element {
|
||||
card(vstack(controls).spacing(10.0)).into()
|
||||
}
|
||||
|
||||
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
|
||||
/// does. Read-only — the bindings themselves live in the input hook (`crate::input`); this is the
|
||||
/// Windows analogue of that window, so both clients document the same set.
|
||||
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("F11", "Toggle fullscreen"),
|
||||
(
|
||||
"Ctrl+Alt+Shift+Q",
|
||||
"Release captured input (click the stream to recapture)",
|
||||
),
|
||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
||||
];
|
||||
|
||||
/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill.
|
||||
fn key_chip(keys: &str) -> Element {
|
||||
border(text_block(keys).font_size(12.0).semibold())
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_thickness(uniform(1.0))
|
||||
.corner_radius(6.0)
|
||||
.padding(edges(8.0, 3.0, 8.0, 3.0))
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A read-only reference card listing the in-stream keyboard shortcuts — the Windows counterpart of
|
||||
/// the GTK client's Keyboard Shortcuts window. One grid, chord chip then action, so the actions
|
||||
/// line up across rows.
|
||||
fn shortcuts_reference() -> Element {
|
||||
let mut children: Vec<Element> = Vec::new();
|
||||
for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() {
|
||||
let row = i as i32;
|
||||
children.push(key_chip(keys).grid_row(row).grid_column(0));
|
||||
let action_cell: Element = text_block(*action)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into();
|
||||
children.push(action_cell.grid_row(row).grid_column(1));
|
||||
}
|
||||
let table = grid(children)
|
||||
.columns([GridLength::Auto, GridLength::Star(1.0)])
|
||||
.rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()])
|
||||
.column_spacing(12.0)
|
||||
.row_spacing(6.0);
|
||||
card(vstack((
|
||||
text_block("In-stream keyboard shortcuts")
|
||||
.semibold()
|
||||
.margin(edges(0.0, 0.0, 0.0, 8.0)),
|
||||
table,
|
||||
)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
|
||||
/// one pane item per section, the section's card as the content, the built-in back arrow
|
||||
/// returning to the host list. `section`/`set_section` are the selected pane tag, held in ROOT
|
||||
@@ -399,16 +346,11 @@ pub(crate) fn settings_page(
|
||||
),
|
||||
"input" => (
|
||||
"Input",
|
||||
vstack((
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
shortcuts_reference(),
|
||||
))
|
||||
.spacing(14.0)
|
||||
.into(),
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
),
|
||||
"audio" => (
|
||||
"Audio",
|
||||
|
||||
@@ -179,7 +179,11 @@ pub fn uninstall() {
|
||||
}
|
||||
}
|
||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||
set_captured(&mut st, false); // hand the cursor back + flush held state
|
||||
// Hand the cursor back + flush held state.
|
||||
set_captured(&mut st, false);
|
||||
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
|
||||
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
|
||||
exit_fullscreen(HWND(st.hwnd as *mut _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,73 +248,104 @@ fn set_locked(st: &mut State, on: bool) {
|
||||
st.locked = on;
|
||||
}
|
||||
|
||||
/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering,
|
||||
/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole
|
||||
/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles
|
||||
/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor
|
||||
/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with
|
||||
/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
||||
fn toggle_fullscreen(hwnd: isize) {
|
||||
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
|
||||
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
|
||||
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
|
||||
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
|
||||
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
|
||||
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Whether our top-level window is currently borderless-fullscreen. Entering strips
|
||||
/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`].
|
||||
fn is_fullscreen(hwnd: HWND) -> bool {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 }
|
||||
}
|
||||
|
||||
/// Enter borderless fullscreen: remember the window placement, drop the frame
|
||||
/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns
|
||||
/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the
|
||||
/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
||||
fn enter_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos,
|
||||
GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
||||
WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
// The pre-fullscreen placement, so exiting restores the exact windowed size + position. Only
|
||||
// ever touched on the UI thread (the hook proc), but a Mutex keeps the static sound + `Sync`.
|
||||
static SAVED: Mutex<Option<WINDOWPLACEMENT>> = Mutex::new(None);
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
if style & overlapped != 0 {
|
||||
// Windowed → fullscreen: remember where we were, drop the frame, cover the monitor.
|
||||
let mut wp = WINDOWPLACEMENT {
|
||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool()
|
||||
{
|
||||
*SAVED.lock().unwrap() = Some(wp);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||
let r = mi.rcMonitor;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fullscreen → windowed: restore the frame, then the saved placement.
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
|
||||
if let Some(wp) = SAVED.lock().unwrap().take() {
|
||||
let _ = SetWindowPlacement(hwnd, &wp);
|
||||
}
|
||||
let mut wp = WINDOWPLACEMENT {
|
||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
|
||||
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||
let r = mi.rcMonitor;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we
|
||||
/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop.
|
||||
fn exit_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else {
|
||||
return; // never went fullscreen — nothing to restore
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
|
||||
let _ = SetWindowPlacement(hwnd, &wp);
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into
|
||||
/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too.
|
||||
fn toggle_fullscreen(hwnd: isize) {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
if is_fullscreen(hwnd) {
|
||||
exit_fullscreen(hwnd);
|
||||
} else {
|
||||
enter_fullscreen(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = c.send_input(&InputEvent {
|
||||
kind,
|
||||
|
||||
@@ -1227,8 +1227,11 @@ impl Worker {
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state periodically, so a generous duration with
|
||||
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
|
||||
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||
/// needs to outlive a couple of refresh periods: long enough that one or two lost
|
||||
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state
|
||||
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of
|
||||
/// droning for seconds.
|
||||
fn render_feedback(&mut self) {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
@@ -1240,7 +1243,7 @@ impl Worker {
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
if let Err(e) = p.set_rumble(low, high, 1_500) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
|
||||
@@ -530,7 +530,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
SessionEvent::Stats(s) => {
|
||||
st.osd_text = stats_text(&st.mode_line, &s, &st.presented, st.hdr);
|
||||
st.osd_text = stats_text(
|
||||
&st.mode_line,
|
||||
&s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
);
|
||||
if print_stats {
|
||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
||||
}
|
||||
@@ -627,6 +633,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// --- Frames: drain to the newest, upload + present -------------------------------
|
||||
let mut presented_video = false;
|
||||
if let Some(st) = &mut stream {
|
||||
// Mastering metadata (0xCE) → the presentation engine, ahead of the frame
|
||||
// that needs it. Low-rate (session start + mastering changes / keyframes).
|
||||
if let Some(c) = &st.connector {
|
||||
while let Ok(m) = c.next_hdr_meta(Duration::ZERO) {
|
||||
presenter.set_hdr_metadata(m);
|
||||
}
|
||||
}
|
||||
let mut newest: Option<DecodedFrame> = None;
|
||||
while let Ok(f) = st.handle.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
@@ -785,13 +798,27 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
||||
|
||||
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
|
||||
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
|
||||
fn stats_text(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) -> String {
|
||||
///
|
||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||
fn stats_text(
|
||||
mode_line: &str,
|
||||
s: &Stats,
|
||||
p: &PresentedWindow,
|
||||
hdr_stream: bool,
|
||||
hdr_display: bool,
|
||||
) -> String {
|
||||
let mut text = format!(
|
||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||
s.fps,
|
||||
s.mbps,
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
if hdr { " · HDR" } else { "" },
|
||||
match (hdr_stream, hdr_display) {
|
||||
(true, true) => " · HDR",
|
||||
(true, false) => " · HDR→SDR",
|
||||
_ => "",
|
||||
},
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
||||
|
||||
+121
-10
@@ -9,10 +9,12 @@
|
||||
//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the
|
||||
//! decoder on software.
|
||||
//!
|
||||
//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by
|
||||
//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is
|
||||
//! arrival-paced by the caller: a frame input on each decoded frame,
|
||||
//! `FrameInput::Redraw` re-blits the retained video image (expose/resize redraws).
|
||||
//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX
|
||||
//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate`
|
||||
//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not
|
||||
//! block in FIFO's present queue). Present is arrival-paced by the caller: a frame
|
||||
//! input on each decoded frame, `FrameInput::Redraw` re-blits the retained video image
|
||||
//! (expose/resize redraws).
|
||||
|
||||
use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
|
||||
use crate::dmabuf::{self, HwFrame};
|
||||
@@ -340,6 +342,12 @@ pub struct Presenter {
|
||||
hdr10_format: Option<vk::SurfaceFormatKHR>,
|
||||
/// PQ frames are on screen and the swapchain is in HDR10 mode.
|
||||
hdr_active: bool,
|
||||
/// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do).
|
||||
hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>,
|
||||
/// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the
|
||||
/// swapchain whenever HDR10 mode is live; `None` until the first datagram lands
|
||||
/// (a generic HDR10 baseline is pushed meanwhile).
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// The video image / CSC attachment format for the current mode.
|
||||
video_format: vk::Format,
|
||||
present_mode: vk::PresentModeKHR,
|
||||
@@ -432,6 +440,15 @@ impl Presenter {
|
||||
unavailable"
|
||||
);
|
||||
}
|
||||
// Static HDR metadata (ST.2086 mastering + CLL) to the presentation engine.
|
||||
// Compositors key their "this app is HDR" signaling on the client pushing
|
||||
// metadata via vkSetHdrMetadataEXT in addition to picking the HDR10 colorspace
|
||||
// (gamescope's SteamOS HDR badge and per-app tone-map targets among them) —
|
||||
// the colorspace alone leaves the app looking SDR to the shell.
|
||||
let has_hdr_metadata = has(ash::ext::hdr_metadata::NAME);
|
||||
if has_hdr_metadata {
|
||||
dev_exts.push(ash::ext::hdr_metadata::NAME.as_ptr());
|
||||
}
|
||||
|
||||
// --- Vulkan Video decode (the FFmpeg-on-our-device path) ---------------------
|
||||
// Probed, never required: a capable stack gets the video extensions, a second
|
||||
@@ -557,6 +574,8 @@ impl Presenter {
|
||||
}
|
||||
.context("vkCreateDevice")?;
|
||||
let swap_d = ash::khr::swapchain::Device::new(&instance, &device);
|
||||
let hdr_metadata_d =
|
||||
has_hdr_metadata.then(|| ash::ext::hdr_metadata::Device::new(&instance, &device));
|
||||
let queue = unsafe { device.get_device_queue(qfi, 0) };
|
||||
let hw = if hw_capable {
|
||||
Some(HwCtx {
|
||||
@@ -578,6 +597,9 @@ impl Presenter {
|
||||
device_extensions
|
||||
.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n)));
|
||||
}
|
||||
if has_hdr_metadata {
|
||||
device_extensions.push(CString::from(ash::ext::hdr_metadata::NAME));
|
||||
}
|
||||
device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n)));
|
||||
Some(pf_client_core::video::VulkanDecodeDevice {
|
||||
get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize,
|
||||
@@ -603,7 +625,13 @@ impl Presenter {
|
||||
|
||||
let (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
|
||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
||||
tracing::info!(?format, ?hdr10_format, ?present_mode, "swapchain config");
|
||||
tracing::info!(
|
||||
?format,
|
||||
?hdr10_format,
|
||||
?present_mode,
|
||||
hdr_metadata = has_hdr_metadata,
|
||||
"swapchain config"
|
||||
);
|
||||
let overlay_pipe = OverlayPipe::new(&device, format.format)?;
|
||||
|
||||
let cmd_pool = unsafe {
|
||||
@@ -651,6 +679,8 @@ impl Presenter {
|
||||
format,
|
||||
hdr10_format,
|
||||
hdr_active: false,
|
||||
hdr_metadata_d,
|
||||
hdr_meta: None,
|
||||
video_format: vk::Format::R8G8B8A8_UNORM,
|
||||
present_mode,
|
||||
swapchain: vk::SwapchainKHR::null(),
|
||||
@@ -762,9 +792,74 @@ impl Presenter {
|
||||
images = self.images.len(),
|
||||
"swapchain (re)created"
|
||||
);
|
||||
// HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed
|
||||
// again (this also covers set_hdr_mode's entry into HDR10, which lands here).
|
||||
if self.hdr_active {
|
||||
self.apply_hdr_metadata();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream
|
||||
/// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is
|
||||
/// what user-facing "HDR" indicators should report.
|
||||
pub fn hdr_active(&self) -> bool {
|
||||
self.hdr_active
|
||||
}
|
||||
|
||||
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
|
||||
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
|
||||
/// idempotent per distinct value — callers just drain the plane into it.
|
||||
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
|
||||
if self.hdr_meta == Some(meta) {
|
||||
return;
|
||||
}
|
||||
self.hdr_meta = Some(meta);
|
||||
if self.hdr_active {
|
||||
self.apply_hdr_metadata();
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE
|
||||
/// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate
|
||||
/// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves
|
||||
/// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map
|
||||
/// target). No-op where the driver lacks the extension.
|
||||
fn apply_hdr_metadata(&self) {
|
||||
let Some(ext) = &self.hdr_metadata_d else {
|
||||
return;
|
||||
};
|
||||
// Same generic baseline as the Windows presenter: BT.2020 primaries + D65
|
||||
// white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400.
|
||||
let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta {
|
||||
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||
white_point: [15635, 16450],
|
||||
max_display_mastering_luminance: 10_000_000,
|
||||
min_display_mastering_luminance: 1,
|
||||
max_cll: 1000,
|
||||
max_fall: 400,
|
||||
});
|
||||
// Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance
|
||||
// 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in
|
||||
// 0..1 chromaticity and whole nits, primaries named R/G/B.
|
||||
let xy = |p: [u16; 2]| vk::XYColorEXT {
|
||||
x: p[0] as f32 / 50_000.0,
|
||||
y: p[1] as f32 / 50_000.0,
|
||||
};
|
||||
let [g, b, r] = m.display_primaries;
|
||||
let md = vk::HdrMetadataEXT::default()
|
||||
.display_primary_red(xy(r))
|
||||
.display_primary_green(xy(g))
|
||||
.display_primary_blue(xy(b))
|
||||
.white_point(xy(m.white_point))
|
||||
.max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0)
|
||||
.min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0)
|
||||
.max_content_light_level(m.max_cll as f32)
|
||||
.max_frame_average_light_level(m.max_fall as f32);
|
||||
unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) };
|
||||
tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed");
|
||||
}
|
||||
|
||||
/// Whether the hardware (dmabuf) path exists on this device — callers keep the
|
||||
/// decoder on software when it doesn't.
|
||||
pub fn supports_dmabuf(&self) -> bool {
|
||||
@@ -1687,8 +1782,13 @@ fn pick_formats(
|
||||
Ok((sdr, hdr10))
|
||||
}
|
||||
|
||||
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
|
||||
/// a streaming client defaults to tear-free.
|
||||
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||
/// fifo|mailbox|immediate` overrides). Both 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.
|
||||
fn pick_present_mode(
|
||||
surface_i: &ash::khr::surface::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
@@ -1696,9 +1796,9 @@ fn pick_present_mode(
|
||||
) -> Result<vk::PresentModeKHR> {
|
||||
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("mailbox") => vk::PresentModeKHR::MAILBOX,
|
||||
Some("fifo") => vk::PresentModeKHR::FIFO,
|
||||
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||
_ => vk::PresentModeKHR::FIFO,
|
||||
_ => vk::PresentModeKHR::MAILBOX,
|
||||
};
|
||||
Ok(if modes.contains(&want) {
|
||||
want
|
||||
@@ -1795,6 +1895,17 @@ fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphic
|
||||
/// sharing) and transition it for sampling. `src_qf == dst_qf` (or IGNORED/CONCURRENT)
|
||||
/// degrades to a plain layout transition. The matching decode-side acquire happens in
|
||||
/// FFmpeg, keyed off the queue_family we write back after submission.
|
||||
///
|
||||
/// `srcStage` is FRAGMENT_SHADER — NOT TOP_OF_PIPE — deliberately: the submit waits the
|
||||
/// frame's decode-complete timeline semaphore with `wait_dst_stage_mask =
|
||||
/// FRAGMENT_SHADER`, and a semaphore wait only orders operations whose first sync scope
|
||||
/// INTERSECTS that mask (the dependency-chain rule). With TOP_OF_PIPE the barrier's
|
||||
/// layout transition (VIDEO_DECODE_DST/DPB → SHADER_READ_ONLY) formed no chain with the
|
||||
/// wait and could execute while the decode queue was still writing the image. On RADV
|
||||
/// that transition physically touches the image (metadata/decompression), so the race
|
||||
/// showed as green/yellow block corruption exactly at freshly-decoded (damaged) regions
|
||||
/// — the Steam Deck cursor-trail artifact. NVIDIA treats the transition as a no-op,
|
||||
/// which is why the same code looked clean there.
|
||||
fn vkframe_acquire_barrier(
|
||||
device: &ash::Device,
|
||||
cmd: vk::CommandBuffer,
|
||||
@@ -1820,7 +1931,7 @@ fn vkframe_acquire_barrier(
|
||||
unsafe {
|
||||
device.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
|
||||
@@ -314,6 +314,11 @@ pub struct NativeClient {
|
||||
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
||||
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
|
||||
frames_dropped: Arc<AtomicU64>,
|
||||
/// Cumulative count of FEC shards the reassembler recovered (parity repaired a lost data
|
||||
/// packet), mirrored from the data-plane pump's `Session` like `frames_dropped`. Observability
|
||||
/// for the client stats HUDs (the unified spec's per-window `FEC` counter — proof FEC is
|
||||
/// earning its keep); readers window it by diffing successive reads.
|
||||
fec_recovered: Arc<AtomicU64>,
|
||||
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
|
||||
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
|
||||
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
|
||||
@@ -490,6 +495,7 @@ impl NativeClient {
|
||||
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||
let fec_recovered = Arc::new(AtomicU64::new(0));
|
||||
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let host = host.to_string();
|
||||
@@ -499,6 +505,7 @@ impl NativeClient {
|
||||
let mode_slot_w = mode_slot.clone();
|
||||
let probe_w = probe.clone();
|
||||
let frames_dropped_w = frames_dropped.clone();
|
||||
let fec_recovered_w = fec_recovered.clone();
|
||||
let hot_tids_w = hot_tids.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
@@ -550,6 +557,7 @@ impl NativeClient {
|
||||
mode_slot: mode_slot_w,
|
||||
probe: probe_w,
|
||||
frames_dropped: frames_dropped_w,
|
||||
fec_recovered: fec_recovered_w,
|
||||
hot_tids: hot_tids_w,
|
||||
}));
|
||||
})
|
||||
@@ -592,6 +600,7 @@ impl NativeClient {
|
||||
quit,
|
||||
worker: Some(worker),
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
hot_tids,
|
||||
mode: mode_slot,
|
||||
host_fingerprint: fingerprint,
|
||||
@@ -734,6 +743,14 @@ impl NativeClient {
|
||||
self.frames_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Cumulative FEC shards the host→client reassembler recovered (a parity shard repaired a lost
|
||||
/// data packet — loss that never became a dropped frame). Monotonic for the session; a stats
|
||||
/// HUD windows it by diffing successive reads, pairing it with
|
||||
/// [`frames_dropped`](Self::frames_dropped) (the losses FEC could NOT absorb).
|
||||
pub fn fec_recovered_shards(&self) -> u64 {
|
||||
self.fec_recovered.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
|
||||
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
|
||||
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
|
||||
@@ -987,6 +1004,7 @@ struct WorkerArgs {
|
||||
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
frames_dropped: Arc<AtomicU64>,
|
||||
fec_recovered: Arc<AtomicU64>,
|
||||
hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||
}
|
||||
|
||||
@@ -1024,6 +1042,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
mode_slot,
|
||||
probe,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
hot_tids,
|
||||
} = args;
|
||||
let setup = async {
|
||||
@@ -1362,6 +1381,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||
let st = session.stats();
|
||||
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
|
||||
let probe_active = {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active && !p.done {
|
||||
|
||||
@@ -1349,8 +1349,10 @@ const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
/// kind decodes to `None` and is dropped).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RichInput {
|
||||
/// One touchpad contact. `x`/`y` are normalized `0..=65535` (the host scales to the
|
||||
/// DualSense touchpad resolution); `active = false` lifts the finger.
|
||||
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
|
||||
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
|
||||
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
|
||||
/// the finger.
|
||||
Touchpad {
|
||||
pad: u8,
|
||||
finger: u8,
|
||||
@@ -1368,9 +1370,13 @@ pub enum RichInput {
|
||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
|
||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
||||
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
|
||||
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
|
||||
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
|
||||
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
|
||||
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
|
||||
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
|
||||
/// `TouchpadEx` (`0x03`) indefinitely.
|
||||
TouchpadEx {
|
||||
pad: u8,
|
||||
surface: u8,
|
||||
@@ -1783,6 +1789,15 @@ pub mod endpoint {
|
||||
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||
));
|
||||
t.keep_alive_interval(Some(keep_alive));
|
||||
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
|
||||
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
|
||||
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
|
||||
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
|
||||
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
|
||||
// standing delay that never drains while video stays live. Capping the buffer makes the
|
||||
// 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);
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,10 @@ windows = { version = "0.62", features = [
|
||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||
"Win32_System_Com",
|
||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Kernel",
|
||||
] }
|
||||
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
|
||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||
|
||||
@@ -249,6 +249,7 @@ impl DualSenseManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -267,50 +268,9 @@ impl DualSenseManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
|
||||
// what the kernel advertises as the ABS_MT extents).
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
||||
//! same as a real USB DualSense.
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
|
||||
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
|
||||
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
||||
@@ -125,6 +125,12 @@ pub struct DsState {
|
||||
pub gyro: [i16; 3],
|
||||
pub accel: [i16; 3],
|
||||
pub touch: [Touch; 2],
|
||||
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
|
||||
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
|
||||
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
|
||||
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
|
||||
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
|
||||
pub touch_click: [bool; 2],
|
||||
}
|
||||
|
||||
impl DsState {
|
||||
@@ -235,6 +241,88 @@ impl DsState {
|
||||
_ => 8,
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
|
||||
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
|
||||
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
|
||||
/// 1920×942).
|
||||
///
|
||||
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
|
||||
/// DualSense pad's own (top-left origin), so no flip here.
|
||||
///
|
||||
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
|
||||
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
|
||||
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
|
||||
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
|
||||
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
|
||||
/// serializer ORs them into the touchpad-click button — one clickable pad, either
|
||||
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
|
||||
/// host" gap.
|
||||
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
|
||||
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
|
||||
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
|
||||
// the reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
self.touch[slot] = Touch {
|
||||
active,
|
||||
id: slot as u8,
|
||||
x: scale(x as u32, touch_w),
|
||||
y: scale(y as u32, touch_h),
|
||||
};
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
|
||||
self.gyro = gyro;
|
||||
self.accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
|
||||
let half = touch_w / 2;
|
||||
let (slot, tx) = match surface {
|
||||
// The single / DualSense pad: full extent, slot by finger.
|
||||
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
|
||||
// Steam LEFT pad → contact 0 on the left half.
|
||||
1 => (0, scale(n(x), half)),
|
||||
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
|
||||
_ => (1, half + scale(n(x), half)),
|
||||
};
|
||||
self.touch[slot] = Touch {
|
||||
active: touch,
|
||||
id: slot as u8,
|
||||
x: tx,
|
||||
y: scale(n(y), touch_h),
|
||||
};
|
||||
self.touch_click[slot] = click;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
|
||||
/// rich-plane pad click is held (see [`DsState::touch_click`]).
|
||||
pub fn buttons2_with_click(&self) -> u8 {
|
||||
let mut b = self.buttons[2];
|
||||
if self.touch_click.iter().any(|c| *c) {
|
||||
b |= btn2::TOUCHPAD;
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
|
||||
@@ -253,7 +341,7 @@ pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8,
|
||||
r[7] = seq; // seq_number (struct off 6)
|
||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||
r[9] = st.buttons[1]; // off 8
|
||||
r[10] = st.buttons[2]; // off 9
|
||||
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
|
||||
r[11] = st.buttons[3]; // off 10
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
||||
@@ -309,7 +397,12 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
if flag0 & 0x03 != 0 {
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
@@ -350,6 +443,127 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
|
||||
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
|
||||
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||
/// button bit in the serialized report.
|
||||
#[test]
|
||||
fn steam_surfaces_split_the_touchpad() {
|
||||
let mut s = DsState::neutral();
|
||||
// Left pad, centre → middle of the LEFT half.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[0].active);
|
||||
assert_eq!(s.touch[0].id, 0);
|
||||
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
|
||||
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
|
||||
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: i16::MAX,
|
||||
y: i16::MIN,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[1].active);
|
||||
assert_eq!(s.touch[1].id, 1);
|
||||
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
|
||||
assert_eq!(s.touch[1].y, 0);
|
||||
// The right pad's click reaches the (single) touchpad-click button bit.
|
||||
assert!(s.touch_click[1]);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &s, 0, 0);
|
||||
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
// Releasing the click clears the bit again.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
|
||||
}
|
||||
|
||||
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
|
||||
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
|
||||
#[test]
|
||||
fn single_surface_spans_full_pad() {
|
||||
let mut s = DsState::neutral();
|
||||
s.apply_rich(
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 65535,
|
||||
y: 65535,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[0].x, s.touch[0].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 0,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: i16::MAX,
|
||||
y: i16::MAX,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[1].x, s.touch[1].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
// Motion is unit-passthrough (wire is already DualSense convention).
|
||||
s.apply_rich(
|
||||
RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [100, -200, 300],
|
||||
accel: [-1000, 2000, -3000],
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.gyro, [100, -200, 300]);
|
||||
assert_eq!(s.accel, [-1000, 2000, -3000]);
|
||||
}
|
||||
|
||||
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
||||
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
||||
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
|
||||
@@ -236,13 +236,23 @@ impl SteamState {
|
||||
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
|
||||
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
|
||||
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
|
||||
///
|
||||
/// The wire's touch coordinates are SCREEN convention — +y DOWN, what SDL/Windows/Android
|
||||
/// capture APIs all produce — but the Deck's raw trackpad fields are stick convention
|
||||
/// (+y UP, centre origin), and Steam Input parses our report as real Deck hardware. Y is
|
||||
/// therefore negated here, on the device boundary; leaving it through was the "both
|
||||
/// trackpads inverted" bug the first live Deck-to-Deck session surfaced (2026-07-08).
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
/// Screen-convention (+down) wire Y → Deck raw (+up), saturating (-32768 has no i16 negation).
|
||||
fn flip_y(y: i16) -> i16 {
|
||||
(y as i32).saturating_neg().clamp(-32768, 32767) as i16
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad { active, x, y, .. } => {
|
||||
self.press(btn::RPAD_TOUCH, active);
|
||||
// Normalized 0..=65535 (centre 32768) → the pad's centred s16 range.
|
||||
// Normalized 0..=65535 (centre 32768, +y down) → the pad's centred s16 range (+y up).
|
||||
self.rpad_x = ((x as i32) - 32768) as i16;
|
||||
self.rpad_y = ((y as i32) - 32768) as i16;
|
||||
self.rpad_y = (32768 - (y as i32)).min(32767) as i16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire carries DualSense-convention units (what every client capture emits); the
|
||||
@@ -259,18 +269,18 @@ impl SteamState {
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 =
|
||||
// Signed centre-0 x maps straight in; y flips to the Deck's +up. surface 1 =
|
||||
// left pad, anything else (0 single / 2 right) = right pad.
|
||||
if surface == 1 {
|
||||
self.press(btn::LPAD_TOUCH, touch);
|
||||
self.press(btn::LPAD_CLICK, click);
|
||||
self.lpad_x = x;
|
||||
self.lpad_y = y;
|
||||
self.lpad_y = flip_y(y);
|
||||
} else {
|
||||
self.press(btn::RPAD_TOUCH, touch);
|
||||
self.press(btn::RPAD_CLICK, click);
|
||||
self.rpad_x = x;
|
||||
self.rpad_y = y;
|
||||
self.rpad_y = flip_y(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,9 +567,9 @@ mod tests {
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
||||
assert_eq!(s.rpad_y, -32768); // 0-32768
|
||||
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||
assert_eq!(s.rpad_y, 32767); // wire y=0 = TOP (screen conv) → Deck raw +up (clamped)
|
||||
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||
s.apply_rich(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [1000, -2000, 0],
|
||||
@@ -570,7 +580,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (signed coords pass straight through).
|
||||
/// 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).
|
||||
#[test]
|
||||
fn back_buttons_and_dual_trackpad_mapping() {
|
||||
let s = SteamState::from_gamepad(
|
||||
@@ -601,7 +612,7 @@ mod tests {
|
||||
});
|
||||
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
|
||||
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
|
||||
assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000));
|
||||
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
@@ -613,7 +624,20 @@ mod tests {
|
||||
pressure: 0,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000));
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
|
||||
|
||||
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: -32768,
|
||||
pressure: 0,
|
||||
});
|
||||
assert_eq!(s.rpad_y, 32767);
|
||||
}
|
||||
|
||||
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
||||
|
||||
@@ -442,6 +442,7 @@ impl DualSenseWindowsManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -458,46 +459,9 @@ impl DualSenseWindowsManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ impl DualShock4WindowsManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -224,46 +225,9 @@ impl DualShock4WindowsManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ mod discovery;
|
||||
mod wol;
|
||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/crash.rs"]
|
||||
mod crash;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/dmabuf_fence.rs"]
|
||||
mod dmabuf_fence;
|
||||
@@ -111,6 +114,35 @@ fn main() {
|
||||
.init();
|
||||
}
|
||||
|
||||
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
|
||||
// prints only to stderr — absent from the web console's Logs tab (the ring) and gone entirely
|
||||
// when stderr is detached — so a field report reads "host died, zero errors in the logs".
|
||||
// The default hook still runs afterwards for the usual stderr message/abort behavior.
|
||||
let default_panic = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// Manual payload downcast (`payload_as_str` needs Rust 1.91; workspace MSRV is 1.82).
|
||||
let payload = info
|
||||
.payload()
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("<non-string panic payload>");
|
||||
tracing::error!(
|
||||
thread = std::thread::current().name().unwrap_or("<unnamed>"),
|
||||
location = %info
|
||||
.location()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "<unknown>".into()),
|
||||
backtrace = %std::backtrace::Backtrace::force_capture(),
|
||||
"PANIC: {payload}"
|
||||
);
|
||||
default_panic(info);
|
||||
}));
|
||||
// Native crashes (an access violation inside a GPU runtime/driver DLL) are logged by a
|
||||
// last-resort SEH filter for the same reason — they otherwise kill the host with no trace.
|
||||
#[cfg(target_os = "windows")]
|
||||
crash::install();
|
||||
|
||||
if let Err(e) = real_main() {
|
||||
tracing::error!("{e:#}");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -1159,14 +1159,20 @@ async fn serve_session(
|
||||
// grant persists across sessions; this thread owns the session's virtual gamepads (uinput,
|
||||
// per-session) and sends force feedback back over `conn`. It exits when the channel closes
|
||||
// (datagram task ends on disconnect) — fresh gamepad state per session.
|
||||
let (input_tx, input_rx) = std::sync::mpsc::channel::<InputEvent>();
|
||||
let (rich_tx, rich_rx) = std::sync::mpsc::channel::<punktfunk_core::quic::RichInput>();
|
||||
//
|
||||
// ONE channel for both event kinds deliberately: rich input (gyro at the pad's report
|
||||
// rate) used to ride a second channel that the thread only drained after the main
|
||||
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
|
||||
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
|
||||
// whichever arrives.
|
||||
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
|
||||
let rich_tx = input_tx.clone();
|
||||
let input_handle = {
|
||||
let conn = conn.clone();
|
||||
let gamepad = welcome.gamepad;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-input".into())
|
||||
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx, gamepad))
|
||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
||||
.context("spawn input thread")?
|
||||
};
|
||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||
@@ -1185,7 +1191,7 @@ async fn serve_session(
|
||||
let _ = mic_tx.try_send(opus.to_vec());
|
||||
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
|
||||
rich_count += 1;
|
||||
if rich_tx.send(rich).is_err() {
|
||||
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Some(mut ev) = InputEvent::decode(&d) {
|
||||
@@ -1201,7 +1207,7 @@ async fn serve_session(
|
||||
) {
|
||||
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
|
||||
}
|
||||
if input_tx.send(ev).is_err() {
|
||||
if input_tx.send(ClientInput::Event(ev)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1706,21 +1712,37 @@ impl PadBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
enum ClientInput {
|
||||
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
|
||||
Event(InputEvent),
|
||||
/// The 0xCC plane: touchpad contacts + motion samples.
|
||||
Rich(punktfunk_core::quic::RichInput),
|
||||
}
|
||||
|
||||
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
|
||||
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
|
||||
/// client→host input (touchpad / motion, `rich_rx`) merged in and feedback pumped between
|
||||
/// events — rumble on the universal datagram plane, DualSense LED/trigger feedback on the
|
||||
/// HID-output plane. The gamepads are created and torn down with the session; the
|
||||
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
|
||||
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
|
||||
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
|
||||
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
|
||||
/// across sessions.
|
||||
fn input_thread(
|
||||
rx: std::sync::mpsc::Receiver<InputEvent>,
|
||||
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
|
||||
rx: std::sync::mpsc::Receiver<ClientInput>,
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
) {
|
||||
let mut pads = PadBackend::select(gamepad);
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
let mut motion_gaps_us: Vec<u32> = Vec::new();
|
||||
let mut last_motion: Option<std::time::Instant> = None;
|
||||
let mut motion_window = std::time::Instant::now();
|
||||
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
|
||||
let mut pad_mask = 0u16;
|
||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
||||
@@ -1745,7 +1767,39 @@ fn input_thread(
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||
Ok(ev) => match ev.kind {
|
||||
// Rich input (touchpad / motion) — applied the moment it arrives; the single
|
||||
// channel means a gyro sample never waits out the 4 ms timeout behind an idle
|
||||
// button plane.
|
||||
Ok(ClientInput::Rich(rich)) => {
|
||||
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(prev) = last_motion.replace(now) {
|
||||
let gap = now.duration_since(prev);
|
||||
if gap < std::time::Duration::from_secs(1) {
|
||||
motion_gaps_us.push(gap.as_micros() as u32);
|
||||
}
|
||||
}
|
||||
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
|
||||
&& !motion_gaps_us.is_empty()
|
||||
{
|
||||
motion_gaps_us.sort_unstable();
|
||||
let p = |q: f64| {
|
||||
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
|
||||
};
|
||||
tracing::debug!(
|
||||
samples = motion_gaps_us.len() + 1,
|
||||
gap_p50_us = p(0.5),
|
||||
gap_p95_us = p(0.95),
|
||||
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
|
||||
"motion cadence (client gyro inter-arrival, 5 s window)"
|
||||
);
|
||||
motion_gaps_us.clear();
|
||||
motion_window = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
pads.apply_rich(rich);
|
||||
}
|
||||
Ok(ClientInput::Event(ev)) => match ev.kind {
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
// A bad index / unknown axis just doesn't update a pad — fall through (no
|
||||
// `continue`) so the rich-input drain + feedback pump below still run every
|
||||
@@ -1784,10 +1838,6 @@ fn input_thread(
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Drain rich client→host input (DualSense touchpad / motion) into the pad backend.
|
||||
while let Ok(rich) = rich_rx.try_recv() {
|
||||
pads.apply_rich(rich);
|
||||
}
|
||||
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
|
||||
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
|
||||
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
|
||||
@@ -2091,6 +2141,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
|
||||
// so it folds to Xbox360 for now (Windows Steam devices are M7).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
||||
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
||||
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
||||
// all of that silently.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -3107,6 +3162,13 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
|
||||
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
|
||||
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
||||
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints —
|
||||
// the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop"
|
||||
// (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead
|
||||
// of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with
|
||||
// the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn.
|
||||
// `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before.
|
||||
vd.set_quit_flag(quit.clone());
|
||||
// Per-session launch (non-Windows): hand the resolved command to the backend instance so
|
||||
// gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions
|
||||
// can't stomp each other's launch target. The other backends' default `set_launch_command` is a
|
||||
@@ -4180,6 +4242,13 @@ mod tests {
|
||||
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||
|
||||
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
|
||||
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -122,6 +122,14 @@ pub trait VirtualDisplay: Send {
|
||||
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
|
||||
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
|
||||
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
||||
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
|
||||
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
|
||||
/// display down IMMEDIATELY, skipping the keep-alive linger — the Windows analogue of the Linux
|
||||
/// registry's `Linger::Immediate` path. Carried on the backend instance; set once before
|
||||
/// [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay backend needs it (its
|
||||
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
|
||||
/// Linux backends get the flag through `registry::acquire`).
|
||||
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
|
||||
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||
|
||||
@@ -95,8 +95,9 @@ pub fn acquire(
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
|
||||
// through there yet, so the flag is accepted but unused off Linux.
|
||||
// Windows leases in the manager (its own linger); its deliberate-quit skip is wired through
|
||||
// `VirtualDisplay::set_quit_flag` on the backend instance (set by the session before any
|
||||
// `create`, so the retry-hold lease gets it too) — not through this parameter.
|
||||
let _ = quit;
|
||||
vd.create(mode)
|
||||
}
|
||||
@@ -602,18 +603,25 @@ mod linux {
|
||||
Ok(output_for(node_id, preferred_mode, gen, quit, false))
|
||||
}
|
||||
|
||||
/// The linger a releasing session actually gets. A deliberate quit (`force_immediate` — the
|
||||
/// client closed with the quit code, a user "stop") downgrades a linger WINDOW to an immediate
|
||||
/// teardown; a bare disconnect honors the policy. `keep_alive = forever` (the gaming-rig
|
||||
/// preset) OUTRANKS the quit: its promise is "the screen stays alive", so a deliberate quit
|
||||
/// still pins — only an explicit `/display/release` frees it.
|
||||
fn effective_linger(force_immediate: bool, policy: Linger) -> Linger {
|
||||
match (force_immediate, policy) {
|
||||
(true, Linger::Forever) => Linger::Forever,
|
||||
(true, _) => Linger::Immediate,
|
||||
(false, l) => l,
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
|
||||
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
|
||||
/// the lock is released.
|
||||
fn release(gen: u64, force_immediate: bool) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
|
||||
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
|
||||
let linger = if force_immediate {
|
||||
Linger::Immediate
|
||||
} else {
|
||||
linger()
|
||||
};
|
||||
let linger = effective_linger(force_immediate, linger());
|
||||
let (torn_down, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
@@ -965,6 +973,25 @@ mod linux {
|
||||
Box::new(move || f.store(true, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliberate_quit_skips_the_linger_window_but_never_a_pin() {
|
||||
use std::time::Duration;
|
||||
// Quit downgrades a linger window (and a no-linger policy stays immediate)…
|
||||
assert_eq!(
|
||||
effective_linger(true, Linger::For(Duration::from_secs(10))),
|
||||
Linger::Immediate
|
||||
);
|
||||
assert_eq!(effective_linger(true, Linger::Immediate), Linger::Immediate);
|
||||
// …but never a pin: keep_alive=forever (gaming-rig) promises the screen stays alive.
|
||||
assert_eq!(effective_linger(true, Linger::Forever), Linger::Forever);
|
||||
// A bare disconnect honors the policy untouched.
|
||||
assert_eq!(
|
||||
effective_linger(false, Linger::For(Duration::from_secs(10))),
|
||||
Linger::For(Duration::from_secs(10))
|
||||
);
|
||||
assert_eq!(effective_linger(false, Linger::Forever), Linger::Forever);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -395,6 +395,7 @@ impl VirtualDisplayManager {
|
||||
&'static self,
|
||||
mode: Mode,
|
||||
client_fp: Option<[u8; 32]>,
|
||||
quit: Option<Arc<AtomicBool>>,
|
||||
) -> Result<VirtualOutput> {
|
||||
self.ensure_linger_timer();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
@@ -474,7 +475,7 @@ impl VirtualDisplayManager {
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
return Ok(self.output_for(mon));
|
||||
return Ok(self.output_for(mon, quit));
|
||||
}
|
||||
|
||||
// Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a
|
||||
@@ -516,13 +517,14 @@ impl VirtualDisplayManager {
|
||||
},
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let out = self.output_for(&mon);
|
||||
let out = self.output_for(&mon, quit);
|
||||
*state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
|
||||
fn output_for(&'static self, mon: &Monitor) -> VirtualOutput {
|
||||
/// `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see [`Self::release`]).
|
||||
fn output_for(&'static self, mon: &Monitor, quit: Option<Arc<AtomicBool>>) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
|
||||
@@ -530,6 +532,7 @@ impl VirtualDisplayManager {
|
||||
keepalive: Box::new(MonitorLease {
|
||||
mgr: self,
|
||||
gen: mon.gen,
|
||||
quit,
|
||||
}),
|
||||
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
|
||||
// (which delegates to it via `vd.create`) treats it as Owned.
|
||||
@@ -760,6 +763,28 @@ impl VirtualDisplayManager {
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
|
||||
// Wedge visibility: this runs synchronously — usually UNDER the `state` lock (linger timer,
|
||||
// reconnect preempt, quit-skip), so a REMOVE/CCD-restore that never returns (field signature:
|
||||
// Windows AMD reconnects going silently dead) blocks every future `acquire` with NOTHING in the
|
||||
// log. One ERROR line after 10 s turns that silent wedge into a diagnosis.
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let done = done.clone();
|
||||
let target = mon.target_id;
|
||||
thread::Builder::new()
|
||||
.name("vdisplay-teardown-watch".into())
|
||||
.spawn(move || {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
if !done.load(Ordering::SeqCst) {
|
||||
tracing::error!(
|
||||
target_id = target,
|
||||
"virtual-display teardown still running after 10s — the driver \
|
||||
REMOVE/CCD restore looks WEDGED; new sessions will block until it returns"
|
||||
);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
mon.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = mon.pinger.take() {
|
||||
let _ = j.join();
|
||||
@@ -784,12 +809,20 @@ impl VirtualDisplayManager {
|
||||
"virtual-display monitor removed"
|
||||
);
|
||||
}
|
||||
done.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
|
||||
/// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a
|
||||
/// no-op, so it can't tear down the CURRENT monitor.
|
||||
fn release(&self, gen: u64) {
|
||||
/// LINGERs before teardown — unless `quit_now` (the client closed with the QUIT code, a user
|
||||
/// "stop"), which tears the monitor down IMMEDIATELY instead of lingering. That both restores
|
||||
/// the physical displays at the moment the user quits and means a follow-up reconnect finds the
|
||||
/// manager Idle — a clean fresh ADD with the user's think-time as driver settle — instead of
|
||||
/// tripping the Lingering-preempt's back-to-back REMOVE→ADD. `keep_alive = forever` (the
|
||||
/// gaming-rig preset) OUTRANKS the quit: its promise is "the screen stays alive", so a
|
||||
/// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease
|
||||
/// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the
|
||||
/// CURRENT monitor.
|
||||
fn release(&self, gen: u64, quit_now: bool) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let stale = match &*state {
|
||||
MgrState::Active { mon, .. }
|
||||
@@ -805,14 +838,41 @@ impl VirtualDisplayManager {
|
||||
mon,
|
||||
refs: refs - 1,
|
||||
},
|
||||
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
|
||||
// else linger for the policy window before the timer tears it down.
|
||||
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever` —
|
||||
// checked BEFORE the quit, because the gaming-rig preset's contract is "the screen
|
||||
// stays alive": a deliberate quit skips only the linger window, never the pin.
|
||||
MgrState::Active { mon, .. } if keep_alive_forever() => {
|
||||
tracing::info!(
|
||||
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
|
||||
);
|
||||
MgrState::Pinned { mon }
|
||||
}
|
||||
// Last session left on a deliberate quit: tear down NOW (linger skipped). Teardown
|
||||
// runs UNDER the state lock — same shape as the linger timer, and for the same reason: a
|
||||
// racing `acquire` must WAIT the teardown out rather than see Idle and ADD into the
|
||||
// driver's in-flight REMOVE. `device_handle()` is only None if the control device was
|
||||
// never opened — impossible with a monitor live — but fall back to Lingering (the timer
|
||||
// retries) rather than leak the monitor.
|
||||
MgrState::Active { mon, .. } if quit_now => match self.device_handle() {
|
||||
Some(dev) => {
|
||||
tracing::info!(
|
||||
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
||||
);
|
||||
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the
|
||||
// cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` checked
|
||||
// above; cached handles are never closed — a dead one is retired, kept alive). `mon`
|
||||
// was moved out of the `Active` state under the `state` lock, so it is exclusively
|
||||
// owned here — no aliasing.
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
MgrState::Idle
|
||||
}
|
||||
None => MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(linger_ms()),
|
||||
},
|
||||
},
|
||||
// Last session left, no quit signal: linger for the policy window before the timer
|
||||
// tears it down.
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
@@ -948,11 +1008,17 @@ impl VirtualDisplayManager {
|
||||
struct MonitorLease {
|
||||
mgr: &'static VirtualDisplayManager,
|
||||
gen: u64,
|
||||
/// The session's deliberate-quit flag (the client closed with the QUIT application code — a user
|
||||
/// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW
|
||||
/// instead of lingering, mirroring the Linux registry's `Linger::Immediate`. `None` = no signal
|
||||
/// (GameStream sessions, the mgmt reconfigure path) → the linger policy applies.
|
||||
quit: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
self.mgr.release(self.gen);
|
||||
let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst));
|
||||
self.mgr.release(self.gen, quit_now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -595,12 +595,19 @@ pub struct PfVdisplayDisplay {
|
||||
/// The connecting client's cert fingerprint (`None` = anonymous/GameStream → the manager's auto id).
|
||||
/// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
|
||||
client_fp: Option<[u8; 32]>,
|
||||
/// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by
|
||||
/// [`set_quit_flag`](VirtualDisplay::set_quit_flag) before `create`; rides into every lease this
|
||||
/// backend mints so a user "stop" tears the monitor down immediately instead of lingering.
|
||||
quit: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
|
||||
Ok(Self { client_fp: None })
|
||||
Ok(Self {
|
||||
client_fp: None,
|
||||
quit: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,8 +620,12 @@ impl VirtualDisplay for PfVdisplayDisplay {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
self.quit = Some(quit);
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
super::manager::vdm().acquire(mode, self.client_fp)
|
||||
super::manager::vdm().acquire(mode, self.client_fp, self.quit.clone())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Last-resort crash visibility (Windows): an unhandled-SEH filter that logs a native crash —
|
||||
//! an access violation inside a driver/runtime DLL (amfrt64, the GPU user-mode driver, d3d11,
|
||||
//! IddCx plumbing) — through `tracing` before the process dies.
|
||||
//!
|
||||
//! Motivated by the "host went Offline with zero errors in the logs" class of field report: a
|
||||
//! native crash kills the process (dropping the mDNS advert → every client tile flips Offline)
|
||||
//! while the log ring's last entry is a healthy INFO line, so the report arrives with no evidence
|
||||
//! at all. One ERROR line naming the exception code and the faulting module turns that into a
|
||||
//! diagnosis. The Rust-panic analogue (a panic hook that tees into `tracing`) lives in `main()`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::System::Diagnostics::Debug::{
|
||||
SetUnhandledExceptionFilter, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetModuleFileNameW, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
};
|
||||
|
||||
/// Install the process-wide unhandled-exception filter. Call once at startup, after logging init
|
||||
/// (the filter reports through `tracing`, so installing it earlier would log into the void).
|
||||
pub fn install() {
|
||||
// SAFETY: registers a process-wide top-level exception filter; `on_unhandled` is a plain
|
||||
// `extern "system"` fn with the LPTOP_LEVEL_EXCEPTION_FILTER signature and static lifetime.
|
||||
// The returned previous filter is deliberately dropped — we are the first/only installer.
|
||||
unsafe {
|
||||
SetUnhandledExceptionFilter(Some(on_unhandled));
|
||||
}
|
||||
}
|
||||
|
||||
/// STATUS_ACCESS_VIOLATION — the overwhelmingly common native-crash code; its first two
|
||||
/// `ExceptionInformation` slots carry the access kind (0 read / 1 write / 8 execute) and the
|
||||
/// target address, which we surface because they distinguish a wild pointer from a guard page.
|
||||
const STATUS_ACCESS_VIOLATION: i32 = 0xC0000005u32 as i32;
|
||||
|
||||
/// The filter itself. Best-effort by design: it formats and logs, which allocates — if the crash
|
||||
/// is heap corruption this can fault again, in which case the OS terminates us exactly as it was
|
||||
/// about to anyway. Returns `EXCEPTION_CONTINUE_SEARCH` so default handling (WER / a debugger /
|
||||
/// the service supervisor seeing the exit) still runs.
|
||||
unsafe extern "system" fn on_unhandled(info: *const EXCEPTION_POINTERS) -> i32 {
|
||||
let mut code: i32 = 0;
|
||||
let mut addr: usize = 0;
|
||||
let mut av_kind: Option<usize> = None;
|
||||
let mut av_target: Option<usize> = None;
|
||||
// SAFETY: `info` (and `ExceptionRecord`) are supplied by the OS for the duration of this
|
||||
// callback; both are checked non-null before the read, and only plain fields are copied out.
|
||||
unsafe {
|
||||
if !info.is_null() && !(*info).ExceptionRecord.is_null() {
|
||||
let r = &*(*info).ExceptionRecord;
|
||||
code = r.ExceptionCode.0;
|
||||
addr = r.ExceptionAddress as usize;
|
||||
if code == STATUS_ACCESS_VIOLATION && r.NumberParameters >= 2 {
|
||||
av_kind = Some(r.ExceptionInformation[0]);
|
||||
av_target = Some(r.ExceptionInformation[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let module = module_at(addr);
|
||||
tracing::error!(
|
||||
code = %format!("0x{:08x}", code as u32),
|
||||
address = %format!("0x{addr:016x}"),
|
||||
module = %module.as_deref().unwrap_or("<unknown>"),
|
||||
av_kind = av_kind.map(|k| match k {
|
||||
0 => "read",
|
||||
1 => "write",
|
||||
8 => "execute",
|
||||
_ => "other",
|
||||
}),
|
||||
av_target = av_target.map(|t| format!("0x{t:016x}")),
|
||||
"FATAL: unhandled native exception — the host process is about to die"
|
||||
);
|
||||
EXCEPTION_CONTINUE_SEARCH
|
||||
}
|
||||
|
||||
/// Resolve the module (DLL/EXE) containing `addr` — the smoking gun that separates "our bug" from
|
||||
/// "the GPU runtime crashed under us" (amfrt64.dll, atiumd64.dll, nvwgf2umx.dll, d3d11.dll, …).
|
||||
fn module_at(addr: usize) -> Option<String> {
|
||||
if addr == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut hmod = HMODULE::default();
|
||||
// SAFETY: FROM_ADDRESS reinterprets the "module name" parameter as an address inside the
|
||||
// module — `addr as *const u16` is exactly that; UNCHANGED_REFCOUNT means no AddRef, so the
|
||||
// returned HMODULE needs no FreeLibrary. `&mut hmod` is a live out-pointer for the call.
|
||||
unsafe {
|
||||
GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
windows::core::PCWSTR(addr as *const u16),
|
||||
&mut hmod,
|
||||
)
|
||||
.ok()?;
|
||||
}
|
||||
let mut buf = [0u16; 512];
|
||||
// SAFETY: `hmod` is the module handle resolved above; `buf` is a live, writable slice for the
|
||||
// duration of the call. Returns the number of UTF-16 units written (0 on failure).
|
||||
let n = unsafe { GetModuleFileNameW(Some(hmod), &mut buf) } as usize;
|
||||
(n > 0).then(|| String::from_utf16_lossy(&buf[..n.min(buf.len())]))
|
||||
}
|
||||
@@ -98,6 +98,8 @@ package_punktfunk-host() {
|
||||
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
||||
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||
install -Dm0644 "$R/scripts/99-punktfunk-net.conf" "$pkgdir/usr/lib/sysctl.d/99-punktfunk-net.conf"
|
||||
# systemd USER units (the host runs in the graphical session, not as root); repoint ExecStart.
|
||||
|
||||
@@ -77,6 +77,10 @@ post_merge() {
|
||||
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
|
||||
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
|
||||
done
|
||||
# vhci-hcd now, no reboot (modules-load.d/punktfunk.conf covers boot): the usbip transport
|
||||
# that makes the virtual Steam Deck pad a real USB device Steam Input adopts. The udev add
|
||||
# event fires the 60-punktfunk.rules vhci rule, opening the attach files to the input group.
|
||||
[ -f /usr/lib/modules-load.d/punktfunk.conf ] && modprobe vhci-hcd 2>/dev/null || :
|
||||
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
|
||||
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
|
||||
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
|
||||
|
||||
@@ -42,6 +42,8 @@ SHAREDIR="$STAGE/usr/share/$PKG"
|
||||
# --- file layout (matches the RPM %install) ----------------------------------
|
||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
# and high-bitrate frames overflow it (send-side packet loss). systemd-sysctl applies it at boot.
|
||||
install -Dm0644 scripts/99-punktfunk-net.conf "$STAGE/usr/lib/sysctl.d/99-punktfunk-net.conf"
|
||||
|
||||
@@ -195,6 +195,10 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
|
||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
||||
|
||||
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
||||
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
# and high-bitrate frames overflow it (send-side loss). systemd-sysctl applies it at boot.
|
||||
install -Dm0644 scripts/99-punktfunk-net.conf %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
@@ -309,6 +313,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%{_bindir}/punktfunk-host
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
|
||||
|
||||
@@ -10,3 +10,10 @@
|
||||
# sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||
|
||||
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
|
||||
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
|
||||
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
|
||||
# are root-only by default while the host runs as a user service — grant the `input`
|
||||
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
|
||||
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Kernel modules the punktfunk host needs at boot (installed as
|
||||
# /usr/lib/modules-load.d/punktfunk.conf).
|
||||
#
|
||||
# vhci-hcd: the usbip virtual host controller — the transport that makes the virtual
|
||||
# Steam Deck controller a real USB device so Steam Input adopts it (see
|
||||
# inject/linux/steam_usbip.rs and the vhci rule in 60-punktfunk.rules). Without it the
|
||||
# pad falls back to plain UHID, which Steam ignores ("no controller appears").
|
||||
vhci-hcd
|
||||
@@ -55,10 +55,19 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
|
||||
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
|
||||
fresh install) is what creates the task in the first place.
|
||||
|
||||
## Rebuild + redeploy everything
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
|
||||
```
|
||||
|
||||
Thin wrapper: runs `deploy-host.ps1` then `build-web.ps1` in sequence. If the host build/start
|
||||
fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web
|
||||
console step runs.
|
||||
|
||||
## Typical flow after pulling new code
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-host.ps1
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
|
||||
```
|
||||
|
||||
@@ -28,8 +28,18 @@ Get-CimInstance Win32_Process -Filter "Name='bun.exe' OR Name='node.exe'" -Error
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
& schtasks /run /tn $task | Out-Null
|
||||
Start-Sleep 5
|
||||
try {
|
||||
$r = Invoke-WebRequest 'http://127.0.0.1:47992/login' -UseBasicParsing -TimeoutSec 10
|
||||
Write-Host "DONE - web /login -> HTTP $($r.StatusCode)"
|
||||
} catch { Write-Warning "web restarted but /login check failed: $($_.Exception.Message)" }
|
||||
|
||||
# web-run.cmd serves HTTPS-only (PUNKTFUNK_UI_SECURE=1, the host's own cert) - probe with curl.exe
|
||||
# (-k for the self-signed cert; Invoke-WebRequest under Windows PowerShell 5.1, which this script
|
||||
# runs under, has no -SkipCertificateCheck), retrying while the task/bun cold-starts.
|
||||
$code = $null
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
Start-Sleep 2
|
||||
$code = & curl.exe -sk -o NUL -w '%{http_code}' --max-time 5 'https://127.0.0.1:47992/login' 2>$null
|
||||
if ($code -eq '200') { break }
|
||||
}
|
||||
if ($code -eq '200') {
|
||||
Write-Host "DONE - web /login -> HTTP $code"
|
||||
} else {
|
||||
Write-Warning "web restarted but /login check did not return 200 (last: $code)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<#
|
||||
Rebuild + redeploy everything: the Windows host service AND the web management console.
|
||||
Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what
|
||||
each one does on its own (rollback behavior, build env, etc).
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
|
||||
|
||||
Run from an elevated PowerShell. deploy-host.ps1 throws (and rolls itself back) on a failed
|
||||
build/start, which stops this script before the web console step runs.
|
||||
#>
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = $PSScriptRoot
|
||||
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 1/2 host service"
|
||||
Write-Host "=========================================="
|
||||
& (Join-Path $here 'deploy-host.ps1')
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 2/2 web console"
|
||||
Write-Host "=========================================="
|
||||
& (Join-Path $here 'build-web.ps1')
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE - host + web console redeployed."
|
||||
+22
-22
@@ -6,40 +6,40 @@
|
||||
"name": "punktfunk-web",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.121.0",
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"@tanstack/react-start": "^1.168.27",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^12.40.0",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.9.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"motion": "^12.42.2",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"recharts": "^3.9.2",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.1",
|
||||
"@inlang/paraglide-js": "^2.0.0",
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
"@inlang/paraglide-js": "^2.20.2",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"orval": "^8.16.0",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"orval": "^8.20.0",
|
||||
"playwright": "^1.61.1",
|
||||
"storybook": "^10.4.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user