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 Code project instructions — local to each dev box, not part of the repo.
|
||||||
CLAUDE.md
|
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]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2767,7 +2767,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2807,7 +2807,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3014,7 +3014,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3030,7 +3030,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3044,7 +3044,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3067,7 +3067,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3098,7 +3098,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3170,7 +3170,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3184,7 +3184,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.8.3"
|
"version": "0.8.4"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/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 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
|
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||||
// appears in the saved-hosts list.
|
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
|
||||||
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
// 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 {
|
val id = identity ?: run {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
@@ -206,18 +208,25 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
onConnected(handle)
|
onConnected(handle)
|
||||||
} else {
|
} else {
|
||||||
status = "Connection failed — check host/port, PIN, and logcat"
|
|
||||||
discovery.start()
|
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
|
// 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
|
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
// Tailscale/VPN/another subnet — is mDNS-blind forever, and gating the dial on presence bricked
|
||||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
// exactly those reconnects). A genuinely-asleep box is already booting while the dial times out;
|
||||||
// already seen live) dial straight through.
|
// 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?) {
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
if (identity == null) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
@@ -232,23 +241,28 @@ fun ConnectScreen(
|
|||||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
waker.start(
|
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||||
hostName = name,
|
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||||
connectsAfter = true,
|
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
|
||||||
macs = macs,
|
waker.start(
|
||||||
lastIp = targetHost,
|
hostName = name,
|
||||||
isOnline = { liveAdvert() != null },
|
connectsAfter = true,
|
||||||
onOnline = {
|
macs = macs,
|
||||||
val live = liveAdvert()
|
lastIp = targetHost,
|
||||||
// Woke back on a new address? Re-key the saved record so it (and future connects)
|
isOnline = { liveAdvert() != null },
|
||||||
// point at the live one, then dial there.
|
onOnline = {
|
||||||
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
|
val live = liveAdvert()
|
||||||
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
|
// Woke back on a new address? Re-key the saved record so it (and future
|
||||||
savedHosts = knownHostStore.all()
|
// connects) point at the live one, then dial there (no fallback on this
|
||||||
}
|
// redial — a second failure surfaces as the plain error).
|
||||||
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
|
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 {
|
} else {
|
||||||
doConnectDirect(targetHost, targetPort, name, pinHex)
|
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,
|
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
|
||||||
) { update(s.copy(gamepad = it)) },
|
) { update(s.copy(gamepad = it)) },
|
||||||
|
|
||||||
toggle(
|
choice(
|
||||||
"hud", "Interface", "Statistics overlay",
|
"hud", "Interface", "Statistics overlay",
|
||||||
"Show FPS, throughput and latency while streaming.",
|
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||||
s.statsHudEnabled,
|
"A 3-finger tap cycles the tiers live.",
|
||||||
) { update(s.copy(statsHudEnabled = it)) },
|
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||||
|
) { update(s.copy(statsVerbosity = it)) },
|
||||||
toggle(
|
toggle(
|
||||||
"library", null, "Game library",
|
"library", null, "Game library",
|
||||||
"Browse a paired host's games with Y (experimental).",
|
"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. */
|
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
|
||||||
val codec: String = "auto",
|
val codec: String = "auto",
|
||||||
val micEnabled: Boolean = false,
|
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):
|
* 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,
|
* 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. */
|
/** [Settings.touchMode] values; persisted by name. */
|
||||||
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
|
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. */
|
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val prefs =
|
private val prefs =
|
||||||
@@ -97,7 +123,16 @@ class SettingsStore(context: Context) {
|
|||||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
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)
|
touchMode = prefs.getString(K_TOUCH_MODE, null)
|
||||||
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
|
||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// 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)
|
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||||
.putString(K_CODEC, s.codec)
|
.putString(K_CODEC, s.codec)
|
||||||
.putBoolean(K_MIC, s.micEnabled)
|
.putBoolean(K_MIC, s.micEnabled)
|
||||||
.putBoolean(K_HUD, s.statsHudEnabled)
|
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
@@ -140,6 +175,10 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_AUDIO_CH = "audio_channels"
|
const val K_AUDIO_CH = "audio_channels"
|
||||||
const val K_CODEC = "codec"
|
const val K_CODEC = "codec"
|
||||||
const val K_MIC = "mic_enabled"
|
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_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
@@ -269,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
|
|||||||
"gamescope",
|
"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. */
|
/** (mode, label) for the touch-input model. */
|
||||||
val TOUCH_MODE_OPTIONS = listOf(
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
TouchMode.TRACKPAD to "Trackpad",
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
|||||||
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
checked = s.autoWakeEnabled,
|
checked = s.autoWakeEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
SettingDropdown(
|
||||||
title = "Stats overlay",
|
label = "Stats overlay",
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
options = STATS_VERBOSITY_OPTIONS,
|
||||||
checked = s.statsHudEnabled,
|
selected = s.statsVerbosity,
|
||||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
) { 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
|
* 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]:
|
* [NativeBridge.nativeVideoStats]:
|
||||||
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
|
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
|
||||||
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 10–13
|
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
|
||||||
* (present on a current native lib) describe the negotiated video feed and render as a
|
* fec, frames]`.
|
||||||
* 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
|
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||||
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
|
* [StatsVerbosity]):
|
||||||
* older layouts just omit those lines.
|
* - [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
|
@Composable
|
||||||
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
|
internal fun StatsOverlay(
|
||||||
if (s.size < 10) return
|
s: DoubleArray,
|
||||||
|
verbosity: StatsVerbosity,
|
||||||
|
decoderLabel: String = "",
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
|
||||||
val w = s[6].toInt()
|
val w = s[6].toInt()
|
||||||
val h = s[7].toInt()
|
val h = s[7].toInt()
|
||||||
val hz = s[8].toInt()
|
val hz = s[8].toInt()
|
||||||
val latValid = s[4] != 0.0
|
val latValid = s[4] != 0.0
|
||||||
val skew = s[5] != 0.0
|
val skew = s[5] != 0.0
|
||||||
val lost = s[9].toLong()
|
val lost = s[9].toLong()
|
||||||
|
val detailed = verbosity == StatsVerbosity.DETAILED
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
// Compact: everything the glance-value needs on one line, nothing else.
|
||||||
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
|
if (verbosity == StatsVerbosity.COMPACT) {
|
||||||
color = Color.White,
|
statLine(compactLine(s, latValid), Color.White)
|
||||||
fontFamily = FontFamily.Monospace,
|
return@Column
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
if (decoderLabel.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
decoderLabel,
|
|
||||||
color = Color(0xFFB0D0FF),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
videoFeedLine(s)?.let { feed ->
|
|
||||||
Text(
|
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
|
||||||
feed,
|
if (detailed && decoderLabel.isNotEmpty()) {
|
||||||
color = Color.White,
|
statLine(decoderLabel, Color(0xFFB0D0FF))
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
fontSize = 12.sp,
|
if (detailed) {
|
||||||
)
|
videoFeedLine(s)?.let { statLine(it, Color.White) }
|
||||||
}
|
}
|
||||||
if (latValid) {
|
if (latValid) {
|
||||||
val tag = if (skew) "" else " (same-host clock)"
|
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",
|
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
|
||||||
color = Color.White,
|
Color.White,
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
)
|
||||||
if (s.size >= 16) {
|
if (detailed && s.size >= 16) {
|
||||||
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
|
// 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
|
// reported its share this window; otherwise the combined term (old host / no
|
||||||
// matched 0xCF timing).
|
// matched 0xCF timing).
|
||||||
@@ -79,25 +81,60 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
|
|||||||
} else {
|
} else {
|
||||||
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
|
||||||
}
|
}
|
||||||
Text(
|
statLine(equation, Color.White)
|
||||||
equation,
|
|
||||||
color = Color.White,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lost > 0) {
|
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
|
||||||
Text(
|
|
||||||
"lost $lost",
|
|
||||||
color = Color(0xFFFFB0B0),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
* Format the negotiated video-feed descriptor from the trailing four stats doubles
|
||||||
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
|
||||||
|
|||||||
@@ -54,15 +54,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
|
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||||
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
|
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||||
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
|
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||||
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
|
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
|
||||||
// comes from Settings.
|
// 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() }
|
val initialSettings = remember { SettingsStore(context).load() }
|
||||||
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
var stats by remember { mutableStateOf<DoubleArray?>(null) }
|
||||||
var decoderLabel by remember { mutableStateOf("") }
|
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).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
|
// "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
|
// 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.
|
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||||
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
|
||||||
LaunchedEffect(handle, showStats) {
|
LaunchedEffect(handle, statsOn) {
|
||||||
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
|
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
stats = NativeBridge.nativeVideoStats(handle)
|
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
|
// 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.
|
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
|
||||||
if (showStats) {
|
if (statsOn) {
|
||||||
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
|
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
|
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
|
||||||
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
else -> streamTouchInput(
|
else -> streamTouchInput(
|
||||||
handle,
|
handle,
|
||||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
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;
|
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
|
||||||
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
|
||||||
* windows); three-finger tap = [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
|
* 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(
|
internal suspend fun PointerInputScope.streamTouchInput(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
trackpad: Boolean,
|
trackpad: Boolean,
|
||||||
onToggleStats: () -> Unit,
|
onCycleStats: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var lastTapUp = 0L
|
var lastTapUp = 0L
|
||||||
var lastTapX = 0f
|
var lastTapX = 0f
|
||||||
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
|||||||
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
|
||||||
} else if (!moved) {
|
} else if (!moved) {
|
||||||
when {
|
when {
|
||||||
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
|
maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
|
||||||
maxFingers == 2 -> { // two-finger tap → right click
|
maxFingers == 2 -> { // two-finger tap → right click
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
NativeBridge.nativeSendPointerButton(handle, 3, true)
|
||||||
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
NativeBridge.nativeSendPointerButton(handle, 3, false)
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
@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
|
@Test
|
||||||
fun trust() = shootScreen("trust") {
|
fun trust() = shootScreen("trust") {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
|
|||||||
import io.unom.punktfunk.TouchMode
|
import io.unom.punktfunk.TouchMode
|
||||||
import io.unom.punktfunk.SettingsScreen
|
import io.unom.punktfunk.SettingsScreen
|
||||||
import io.unom.punktfunk.StatsOverlay
|
import io.unom.punktfunk.StatsOverlay
|
||||||
|
import io.unom.punktfunk.StatsVerbosity
|
||||||
import io.unom.punktfunk.components.HostCard
|
import io.unom.punktfunk.components.HostCard
|
||||||
import io.unom.punktfunk.components.SectionLabel
|
import io.unom.punktfunk.components.SectionLabel
|
||||||
import io.unom.punktfunk.models.HostStatus
|
import io.unom.punktfunk.models.HostStatus
|
||||||
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
|
|||||||
compositor = 1,
|
compositor = 1,
|
||||||
gamepad = 2,
|
gamepad = 2,
|
||||||
micEnabled = true,
|
micEnabled = true,
|
||||||
statsHudEnabled = true,
|
statsVerbosity = StatsVerbosity.DETAILED,
|
||||||
touchMode = TouchMode.TRACKPAD,
|
touchMode = TouchMode.TRACKPAD,
|
||||||
),
|
),
|
||||||
onChange = {},
|
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
|
@Composable
|
||||||
internal fun StreamScene() {
|
internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -187,17 +191,21 @@ internal fun StreamScene() {
|
|||||||
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
// The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
|
||||||
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
|
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
|
||||||
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
|
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
|
||||||
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
|
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||||
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
|
// video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
|
||||||
// split equation, and the decoder label line shows the ranked low-latency decoder.
|
// 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(
|
StatsOverlay(
|
||||||
doubleArrayOf(
|
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,
|
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",
|
decoderLabel = "c2.qti.hevc.decoder · low-latency",
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
|
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
|
/// 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 {
|
struct OutputReady {
|
||||||
index: usize,
|
index: usize,
|
||||||
pts_us: u64,
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
|
/// 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),
|
Au(Frame),
|
||||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||||
InputAvailable(usize),
|
InputAvailable(usize),
|
||||||
/// A decoded frame is ready (buffer index + echoed pts).
|
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||||
OutputAvailable { index: usize, pts_us: u64 },
|
OutputAvailable {
|
||||||
|
index: usize,
|
||||||
|
pts_us: u64,
|
||||||
|
decoded_ns: i128,
|
||||||
|
},
|
||||||
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
|
||||||
FormatChanged,
|
FormatChanged,
|
||||||
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
/// The codec reported an error; `fatal` when neither recoverable nor transient.
|
||||||
@@ -569,6 +577,10 @@ fn run_async(
|
|||||||
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
let _ = out_tx.send(DecodeEvent::OutputAvailable {
|
||||||
index: idx,
|
index: idx,
|
||||||
pts_us: info.presentation_time_us().max(0) as u64,
|
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| {
|
on_format_changed: Some(Box::new(move |_fmt| {
|
||||||
@@ -697,29 +709,30 @@ fn run_async(
|
|||||||
};
|
};
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
let mut fmt_dirty = false;
|
let mut fmt_dirty = false;
|
||||||
let mut au_dropped = false;
|
let mut aus_dropped: u64 = 0;
|
||||||
if let Some(ev) = ev0 {
|
if let Some(ev) = ev0 {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||||
// presentation across a decode burst, and batched feeding.
|
// presentation across a decode burst, and batched feeding.
|
||||||
while let Ok(ev) = ev_rx.try_recv() {
|
while let Ok(ev) = ev_rx.try_recv() {
|
||||||
au_dropped |= dispatch_event(
|
aus_dropped += u64::from(dispatch_event(
|
||||||
ev,
|
ev,
|
||||||
&mut pending_aus,
|
&mut pending_aus,
|
||||||
&mut free_inputs,
|
&mut free_inputs,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&mut fmt_dirty,
|
&mut fmt_dirty,
|
||||||
&mut fatal,
|
&mut fatal,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
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
|
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||||
// control stream.
|
// control stream.
|
||||||
let dropped = client.frames_dropped();
|
let dropped = client.frames_dropped();
|
||||||
if dropped > last_dropped || au_dropped {
|
if dropped > last_dropped || aus_dropped > 0 {
|
||||||
last_dropped = dropped;
|
last_dropped = dropped;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
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::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::FormatChanged => *fmt_dirty = true,
|
||||||
DecodeEvent::Error { fatal: f } => {
|
DecodeEvent::Error { fatal: f } => {
|
||||||
if f {
|
if f {
|
||||||
@@ -935,15 +956,19 @@ fn present_ready(
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
for o in ready.iter() {
|
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 last = ready.len() - 1;
|
||||||
|
let mut skipped: u64 = 0;
|
||||||
for (i, o) in ready.drain(..).enumerate() {
|
for (i, o) in ready.drain(..).enumerate() {
|
||||||
let render = i == last;
|
let render = i == last;
|
||||||
match codec.release_output_buffer_by_index(o.index, render) {
|
match codec.release_output_buffer_by_index(o.index, render) {
|
||||||
Ok(()) if render => *rendered += 1,
|
Ok(()) if render => *rendered += 1,
|
||||||
Ok(()) => *discarded += 1,
|
Ok(()) => {
|
||||||
|
*discarded += 1;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: release_output_buffer_by_index({}, {render}): {e}",
|
"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
|
/// 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}");
|
log::warn!("decode: release_output_buffer(discard): {e}");
|
||||||
}
|
}
|
||||||
discarded += 1;
|
discarded += 1;
|
||||||
|
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
|
||||||
@@ -1203,18 +1230,20 @@ fn note_decoded(
|
|||||||
in_flight,
|
in_flight,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
buf.info().presentation_time_us().max(0) as u64,
|
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 [`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(
|
fn note_decoded_pts(
|
||||||
stats: &crate::stats::VideoStats,
|
stats: &crate::stats::VideoStats,
|
||||||
in_flight: &mut VecDeque<(u64, i128)>,
|
in_flight: &mut VecDeque<(u64, i128)>,
|
||||||
clock_offset: i64,
|
clock_offset: i64,
|
||||||
pts_us: u64,
|
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.
|
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
|
||||||
let mut received_ns = None;
|
let mut received_ns = None;
|
||||||
while let Some(&(p, r)) = in_flight.front() {
|
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
|
/// `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,
|
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||||||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||||||
/// netP50Ms]`
|
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
|
||||||
/// (the two flags are 1.0/0.0; indexes 0–15 match the previous 16-double layout — 0–13 the original
|
/// (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
|
/// 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
|
/// 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` =
|
/// 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
|
/// 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
|
/// 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).
|
/// the host build too (Kotlin only ever calls it on device).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
|||||||
if h.video.lock().unwrap().is_none() {
|
if h.video.lock().unwrap().is_none() {
|
||||||
return std::ptr::null_mut(); // not streaming → no stats
|
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 mode = h.client.mode();
|
||||||
let color = h.client.color;
|
let color = h.client.color;
|
||||||
let buf: [f64; 18] = [
|
let buf: [f64; 22] = [
|
||||||
snap.fps,
|
snap.fps,
|
||||||
snap.mbps,
|
snap.mbps,
|
||||||
snap.e2e_p50_ms,
|
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.
|
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||||||
snap.host_p50_ms,
|
snap.host_p50_ms,
|
||||||
snap.net_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) {
|
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
|||||||
if handle != 0 {
|
if handle != 0 {
|
||||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
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
|
//! 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
|
//! 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+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
|
//! (`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
|
//! 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
|
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
|
||||||
@@ -54,6 +57,14 @@ struct Inner {
|
|||||||
net_us: Vec<u64>,
|
net_us: Vec<u64>,
|
||||||
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
|
||||||
decode_us: Vec<u64>,
|
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).
|
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
|
||||||
skew_corrected: bool,
|
skew_corrected: bool,
|
||||||
}
|
}
|
||||||
@@ -76,6 +87,16 @@ pub struct Snapshot {
|
|||||||
pub net_p50_ms: f64,
|
pub net_p50_ms: f64,
|
||||||
pub lat_valid: bool,
|
pub lat_valid: bool,
|
||||||
pub skew_corrected: 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.
|
/// 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),
|
host_us: Vec::with_capacity(256),
|
||||||
net_us: Vec::with_capacity(256),
|
net_us: Vec::with_capacity(256),
|
||||||
decode_us: Vec::with_capacity(256),
|
decode_us: Vec::with_capacity(256),
|
||||||
|
skipped: 0,
|
||||||
|
last_dropped_total: 0,
|
||||||
|
last_fec_total: 0,
|
||||||
skew_corrected: false,
|
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
|
/// 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.
|
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
|
||||||
pub fn set_enabled(&self, on: bool) {
|
/// `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);
|
let was = self.enabled.swap(on, Ordering::Relaxed);
|
||||||
if on && !was {
|
if on && !was {
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -131,6 +157,9 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_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);
|
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
|
/// 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
|
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
|
||||||
/// this pts predates the HUD being shown).
|
/// 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.
|
/// 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
|
// Poison-proof for the same reason as `note_received` — a poisoned window still drains
|
||||||
// fine.
|
// fine.
|
||||||
let mut g = self
|
let mut g = self
|
||||||
@@ -255,6 +302,10 @@ impl VideoStats {
|
|||||||
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
net_p50_ms: pctl_ms(&g.net_us, 0.50),
|
||||||
lat_valid: !g.e2e_us.is_empty(),
|
lat_valid: !g.e2e_us.is_empty(),
|
||||||
skew_corrected: g.skew_corrected,
|
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.window_start = Instant::now();
|
||||||
g.frames = 0;
|
g.frames = 0;
|
||||||
@@ -264,6 +315,9 @@ impl VideoStats {
|
|||||||
g.host_us.clear();
|
g.host_us.clear();
|
||||||
g.net_us.clear();
|
g.net_us.clear();
|
||||||
g.decode_us.clear();
|
g.decode_us.clear();
|
||||||
|
g.skipped = 0;
|
||||||
|
g.last_dropped_total = dropped_total;
|
||||||
|
g.last_fec_total = fec_total;
|
||||||
snap
|
snap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,14 +422,24 @@ struct ContentView: View {
|
|||||||
host, launchID: launchID, allowTofu: allowTofu,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess, approvalReq: approvalReq)
|
requestAccess: requestAccess, approvalReq: approvalReq)
|
||||||
}
|
}
|
||||||
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
|
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||||||
// back online — a cold box takes far longer to boot than a connect will sit — showing the
|
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||||||
// "Waking…" overlay meanwhile. Then connect. Otherwise dial straight away.
|
// 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) {
|
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||||
discovery.start() // so we can observe it reappear
|
discovery.start() // so the wake-wait can observe it reappear
|
||||||
waker.start(
|
startSessionDirect(
|
||||||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
host, launchID: launchID, allowTofu: allowTofu,
|
||||||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
requestAccess: requestAccess, approvalReq: approvalReq,
|
||||||
|
onUnreachable: {
|
||||||
|
waker.start(
|
||||||
|
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||||||
|
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
go()
|
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
|
/// 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
|
/// 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(
|
private func startSessionDirect(
|
||||||
_ host: StoredHost, launchID: String? = nil,
|
_ 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)
|
prepareWake(for: host)
|
||||||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||||||
@@ -461,7 +473,8 @@ struct ContentView: View {
|
|||||||
preferredCodec: preferredCodecByte,
|
preferredCodec: preferredCodecByte,
|
||||||
launchID: launchID,
|
launchID: launchID,
|
||||||
allowTofu: allowTofu,
|
allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess)
|
requestAccess: requestAccess,
|
||||||
|
onUnreachable: onUnreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
/// 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
|
/// 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
|
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||||
/// for the wait; nil = trust-on-first-use.
|
/// 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,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
compositor: PunktfunkConnection.Compositor = .auto,
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
@@ -145,7 +149,8 @@ final class SessionModel: ObservableObject {
|
|||||||
launchID: String? = nil,
|
launchID: String? = nil,
|
||||||
allowTofu: Bool = false,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false,
|
autoTrust: Bool = false,
|
||||||
requestAccess: Bool = false) {
|
requestAccess: Bool = false,
|
||||||
|
onUnreachable: (@MainActor () -> Void)? = nil) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
@@ -241,7 +246,11 @@ final class SessionModel: ObservableObject {
|
|||||||
case .failure:
|
case .failure:
|
||||||
self.phase = .idle
|
self.phase = .idle
|
||||||
self.activeHost = nil
|
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
|
// The delegated-approval connect ended without being admitted: the
|
||||||
// operator didn't approve it before the host's park window elapsed (or
|
// operator didn't approve it before the host's park window elapsed (or
|
||||||
// the host was unreachable).
|
// 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 {
|
pub fn run() -> u8 {
|
||||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@@ -164,6 +188,10 @@ mod session_main {
|
|||||||
)
|
)
|
||||||
.init();
|
.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
|
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||||
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
// 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`).
|
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ pub struct AppModel {
|
|||||||
hosts: Controller<HostsPage>,
|
hosts: Controller<HostsPage>,
|
||||||
/// One session child at a time — connects while one runs are ignored.
|
/// One session child at a time — connects while one runs are ignored.
|
||||||
busy: bool,
|
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
|
/// 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
|
/// 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.
|
/// `AppMsg` must stay `Send` for the session child's reader thread.
|
||||||
@@ -68,7 +73,9 @@ pub struct AppModel {
|
|||||||
pub enum AppMsg {
|
pub enum AppMsg {
|
||||||
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
||||||
Connect(ConnectRequest),
|
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),
|
WakeConnect(ConnectRequest),
|
||||||
/// The SPAKE2 PIN ceremony dialog.
|
/// The SPAKE2 PIN ceremony dialog.
|
||||||
Pair(ConnectRequest),
|
Pair(ConnectRequest),
|
||||||
@@ -192,6 +199,7 @@ impl SimpleComponent for AppModel {
|
|||||||
gamepad: init.gamepad,
|
gamepad: init.gamepad,
|
||||||
hosts,
|
hosts,
|
||||||
busy: false,
|
busy: false,
|
||||||
|
wake_fallback: None,
|
||||||
waiting: Rc::new(RefCell::new(None)),
|
waiting: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
install_actions(&model.window, &sender);
|
install_actions(&model.window, &sender);
|
||||||
@@ -293,7 +301,15 @@ impl SimpleComponent for AppModel {
|
|||||||
}
|
}
|
||||||
AppMsg::WakeConnect(req) => {
|
AppMsg::WakeConnect(req) => {
|
||||||
if !self.busy {
|
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) => {
|
AppMsg::Pair(req) => {
|
||||||
@@ -365,11 +381,23 @@ impl SimpleComponent for AppModel {
|
|||||||
self.close_waiting();
|
self.close_waiting();
|
||||||
self.busy = false;
|
self.busy = false;
|
||||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
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) {
|
match (code, error, ended) {
|
||||||
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
||||||
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
|
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
|
||||||
(_, Some((_, true)), _) if !tofu => {
|
(_, 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");
|
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||||
crate::ui_trust::pin_dialog(
|
crate::ui_trust::pin_dialog(
|
||||||
&self.window,
|
&self.window,
|
||||||
@@ -378,10 +406,18 @@ impl SimpleComponent for AppModel {
|
|||||||
req,
|
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
|
(_, Some((msg, _)), _) => self
|
||||||
.hosts
|
.hosts
|
||||||
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
|
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
|
||||||
(-1, None, _) => {} // killed (request-access cancel) — already handled
|
(-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!(
|
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
|
||||||
"Stream session failed (punktfunk-session exit {code})"
|
"Stream session failed (punktfunk-session exit {code})"
|
||||||
))),
|
))),
|
||||||
|
|||||||
@@ -306,7 +306,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
}
|
}
|
||||||
overlay.add_controller(right_click);
|
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 wake_first = !online && !req.mac.is_empty();
|
||||||
let sender = sender.clone();
|
let sender = sender.clone();
|
||||||
returned.connect_activate(move |_| {
|
returned.connect_activate(move |_| {
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ use adw::prelude::*;
|
|||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use relm4::prelude::*;
|
use relm4::prelude::*;
|
||||||
|
|
||||||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet,
|
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||||
/// then we poll mDNS until it comes back online — re-sending every few seconds up to a
|
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||||
/// timeout — and route back into the trust gate, **re-keying the saved record if the
|
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||||
/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the
|
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||||
/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
/// 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(
|
pub fn wake_and_connect(
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
sender: &ComponentSender<AppModel>,
|
sender: &ComponentSender<AppModel>,
|
||||||
|
|||||||
@@ -113,7 +113,12 @@ for scene in "${SCENES[@]}"; do
|
|||||||
"$BIN" >"$WORK/log" 2>&1 &
|
"$BIN" >"$WORK/log" 2>&1 &
|
||||||
pid=$!
|
pid=$!
|
||||||
ready=0
|
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
|
if grep -q "PF_SHOT_READY" "$WORK/log"; then
|
||||||
ready=1
|
ready=1
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ pub(crate) fn initiate(
|
|||||||
target: Target,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
set_screen: &AsyncSetState<Screen>,
|
||||||
set_status: &AsyncSetState<String>,
|
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 known = KnownHosts::load();
|
||||||
let pin = target
|
let pin = target
|
||||||
@@ -36,10 +62,14 @@ pub(crate) fn initiate(
|
|||||||
})
|
})
|
||||||
.and_then(|fp| trust::parse_hex32(&fp));
|
.and_then(|fp| trust::parse_hex32(&fp));
|
||||||
|
|
||||||
|
let opts = ConnectOpts {
|
||||||
|
wake_on_fail,
|
||||||
|
..ConnectOpts::default()
|
||||||
|
};
|
||||||
if let Some(pin) = pin {
|
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 {
|
} 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 {
|
} else {
|
||||||
*ctx.shared.target.lock().unwrap() = target;
|
*ctx.shared.target.lock().unwrap() = target;
|
||||||
set_screen.call(Screen::Pair);
|
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
|
/// silently when the parked connect finally resolves — without touching a screen a new
|
||||||
/// session may already own.
|
/// session may already own.
|
||||||
cancel: Option<Arc<AtomicBool>>,
|
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 {
|
impl Default for ConnectOpts {
|
||||||
@@ -150,6 +187,7 @@ impl Default for ConnectOpts {
|
|||||||
persist_paired: false,
|
persist_paired: false,
|
||||||
awaiting_approval: false,
|
awaiting_approval: false,
|
||||||
cancel: None,
|
cancel: None,
|
||||||
|
wake_on_fail: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +248,8 @@ fn connect_with(
|
|||||||
let tofu = pin.is_none();
|
let tofu = pin.is_none();
|
||||||
let persist_paired = opts.persist_paired;
|
let persist_paired = opts.persist_paired;
|
||||||
let cancel = opts.cancel;
|
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 (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
@@ -264,8 +304,14 @@ fn connect_with(
|
|||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if trust_rejected {
|
if trust_rejected {
|
||||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
// 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();
|
*shared.target.lock().unwrap() = target.clone();
|
||||||
ss.call(Screen::Pair);
|
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 {
|
} else {
|
||||||
ss.call(Screen::Hosts);
|
ss.call(Screen::Hosts);
|
||||||
}
|
}
|
||||||
@@ -310,17 +356,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
|||||||
persist_paired: true,
|
persist_paired: true,
|
||||||
awaiting_approval: true,
|
awaiting_approval: true,
|
||||||
cancel: Some(cancel),
|
cancel: Some(cancel),
|
||||||
|
wake_on_fail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
|
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the FALLBACK after a
|
||||||
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
|
/// failed dial-first attempt ([`initiate_waking`]) to a non-advertising saved host with a MAC.
|
||||||
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
|
/// Send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS for the host to
|
||||||
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
|
/// reappear — re-sending the packet periodically — on a bounded deadline (a cold box takes far
|
||||||
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
|
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
|
||||||
/// IP); on timeout or Cancel we return to the host list.
|
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
|
||||||
pub(crate) fn wake_and_connect(
|
/// the host list.
|
||||||
|
fn wake_and_connect(
|
||||||
ctx: &Arc<AppCtx>,
|
ctx: &Arc<AppCtx>,
|
||||||
target: Target,
|
target: Target,
|
||||||
set_screen: &AsyncSetState<Screen>,
|
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 /
|
//! 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.
|
//! 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::speed::SpeedState;
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc, Target};
|
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();
|
let sa = set_show_add.clone();
|
||||||
move || sa.call(true)
|
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({
|
button("Settings").icon(Symbol::Setting).on_click({
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
move || ss.call(Screen::Settings)
|
move || ss.call(Screen::Settings)
|
||||||
@@ -386,11 +390,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
),
|
),
|
||||||
Some(menu),
|
Some(menu),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
// Saved host with a known MAC that isn't advertising: fire a wake packet and
|
||||||
// the network (re-sending periodically) before dialing — a cold box boots far
|
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
|
||||||
// slower than a connect will sit. An online host dials straight away.
|
// advertises here); only a failed dial falls into the "Waking…" wait. An
|
||||||
|
// online host dials straight away.
|
||||||
if can_wake {
|
if can_wake {
|
||||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
initiate_waking(&ctx2, target.clone(), &ss, &st);
|
||||||
} else {
|
} else {
|
||||||
initiate(&ctx2, target.clone(), &ss, &st);
|
initiate(&ctx2, target.clone(), &ss, &st);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||||
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
//! * [`pair`] — the SPAKE2 PIN pairing ceremony
|
||||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
//! * [`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
|
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
//! present must not go through state/rerender.
|
//! present must not go through state/rerender.
|
||||||
|
|
||||||
mod connect;
|
mod connect;
|
||||||
|
mod help;
|
||||||
mod hosts;
|
mod hosts;
|
||||||
mod licenses;
|
mod licenses;
|
||||||
mod pair;
|
mod pair;
|
||||||
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
|
|||||||
Settings,
|
Settings,
|
||||||
/// Open-source / third-party license notices (reached from Settings).
|
/// Open-source / third-party license notices (reached from Settings).
|
||||||
Licenses,
|
Licenses,
|
||||||
|
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
|
||||||
|
/// button).
|
||||||
|
Help,
|
||||||
Pair,
|
Pair,
|
||||||
/// Per-host network speed test (probe burst + recommended bitrate).
|
/// Per-host network speed test (probe burst + recommended bitrate).
|
||||||
SpeedTest,
|
SpeedTest,
|
||||||
@@ -382,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
set_hover,
|
set_hover,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
|
||||||
// no hooks (they never touch `cx`), so calling them inline is sound.
|
// help_page use no hooks (they never touch `cx`), so calling them inline is sound.
|
||||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||||
Screen::Waking => connect::waking_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,
|
nav_progress,
|
||||||
),
|
),
|
||||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||||
|
Screen::Help => help::help_page(&set_screen),
|
||||||
Screen::Pair => component(pair::pair_page, svc),
|
Screen::Pair => component(pair::pair_page, svc),
|
||||||
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
|
||||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
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()
|
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) —
|
/// 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
|
/// 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
|
/// 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" => (
|
||||||
"Input",
|
"Input",
|
||||||
vstack((
|
settings_card(vec![
|
||||||
settings_card(vec![
|
forward_combo.into(),
|
||||||
forward_combo.into(),
|
pad_combo.into(),
|
||||||
pad_combo.into(),
|
shortcuts_toggle.into(),
|
||||||
shortcuts_toggle.into(),
|
]),
|
||||||
]),
|
|
||||||
shortcuts_reference(),
|
|
||||||
))
|
|
||||||
.spacing(14.0)
|
|
||||||
.into(),
|
|
||||||
),
|
),
|
||||||
"audio" => (
|
"audio" => (
|
||||||
"Audio",
|
"Audio",
|
||||||
|
|||||||
@@ -179,7 +179,11 @@ pub fn uninstall() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
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;
|
st.locked = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering,
|
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
|
||||||
/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole
|
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
|
||||||
/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles
|
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
|
||||||
/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor
|
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
|
||||||
/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with
|
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
|
||||||
/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
|
||||||
fn toggle_fullscreen(hwnd: isize) {
|
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::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||||
};
|
};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos,
|
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
|
||||||
GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||||
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;
|
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||||
unsafe {
|
unsafe {
|
||||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||||
if style & overlapped != 0 {
|
let mut wp = WINDOWPLACEMENT {
|
||||||
// Windowed → fullscreen: remember where we were, drop the frame, cover the monitor.
|
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||||
let mut wp = WINDOWPLACEMENT {
|
..Default::default()
|
||||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
};
|
||||||
..Default::default()
|
let mut mi = MONITORINFO {
|
||||||
};
|
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||||
let mut mi = MONITORINFO {
|
..Default::default()
|
||||||
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() {
|
||||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
|
||||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool()
|
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||||
{
|
let r = mi.rcMonitor;
|
||||||
*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 _ = SetWindowPos(
|
let _ = SetWindowPos(
|
||||||
hwnd,
|
hwnd,
|
||||||
None,
|
None,
|
||||||
0,
|
r.left,
|
||||||
0,
|
r.top,
|
||||||
0,
|
r.right - r.left,
|
||||||
0,
|
r.bottom - r.top,
|
||||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
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) {
|
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
let _ = c.send_input(&InputEvent {
|
let _ = c.send_input(&InputEvent {
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -1227,8 +1227,11 @@ impl Worker {
|
|||||||
|
|
||||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
/// 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
|
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||||
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
|
/// 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) {
|
fn render_feedback(&mut self) {
|
||||||
let Some(connector) = self.attached.clone() else {
|
let Some(connector) = self.attached.clone() else {
|
||||||
return;
|
return;
|
||||||
@@ -1240,7 +1243,7 @@ impl Worker {
|
|||||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
// 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
|
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||||
// client-render.
|
// 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");
|
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(low, high, "rumble: rendered");
|
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) => {
|
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 {
|
if print_stats {
|
||||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
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 -------------------------------
|
// --- Frames: drain to the newest, upload + present -------------------------------
|
||||||
let mut presented_video = false;
|
let mut presented_video = false;
|
||||||
if let Some(st) = &mut stream {
|
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;
|
let mut newest: Option<DecodedFrame> = None;
|
||||||
while let Ok(f) = st.handle.frames.try_recv() {
|
while let Ok(f) = st.handle.frames.try_recv() {
|
||||||
newest = Some(f);
|
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 unified stats window (design/stats-unification.md) as OSD text — multi-line for
|
||||||
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
|
/// 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!(
|
let mut text = format!(
|
||||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||||
s.fps,
|
s.fps,
|
||||||
s.mbps,
|
s.mbps,
|
||||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
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!(
|
text.push_str(&format!(
|
||||||
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
||||||
|
|||||||
+121
-10
@@ -9,10 +9,12 @@
|
|||||||
//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the
|
//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the
|
||||||
//! decoder on software.
|
//! decoder on software.
|
||||||
//!
|
//!
|
||||||
//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by
|
//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX
|
||||||
//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is
|
//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate`
|
||||||
//! arrival-paced by the caller: a frame input on each decoded frame,
|
//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not
|
||||||
//! `FrameInput::Redraw` re-blits the retained video image (expose/resize redraws).
|
//! 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::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
|
||||||
use crate::dmabuf::{self, HwFrame};
|
use crate::dmabuf::{self, HwFrame};
|
||||||
@@ -340,6 +342,12 @@ pub struct Presenter {
|
|||||||
hdr10_format: Option<vk::SurfaceFormatKHR>,
|
hdr10_format: Option<vk::SurfaceFormatKHR>,
|
||||||
/// PQ frames are on screen and the swapchain is in HDR10 mode.
|
/// PQ frames are on screen and the swapchain is in HDR10 mode.
|
||||||
hdr_active: bool,
|
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.
|
/// The video image / CSC attachment format for the current mode.
|
||||||
video_format: vk::Format,
|
video_format: vk::Format,
|
||||||
present_mode: vk::PresentModeKHR,
|
present_mode: vk::PresentModeKHR,
|
||||||
@@ -432,6 +440,15 @@ impl Presenter {
|
|||||||
unavailable"
|
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) ---------------------
|
// --- Vulkan Video decode (the FFmpeg-on-our-device path) ---------------------
|
||||||
// Probed, never required: a capable stack gets the video extensions, a second
|
// Probed, never required: a capable stack gets the video extensions, a second
|
||||||
@@ -557,6 +574,8 @@ impl Presenter {
|
|||||||
}
|
}
|
||||||
.context("vkCreateDevice")?;
|
.context("vkCreateDevice")?;
|
||||||
let swap_d = ash::khr::swapchain::Device::new(&instance, &device);
|
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 queue = unsafe { device.get_device_queue(qfi, 0) };
|
||||||
let hw = if hw_capable {
|
let hw = if hw_capable {
|
||||||
Some(HwCtx {
|
Some(HwCtx {
|
||||||
@@ -578,6 +597,9 @@ impl Presenter {
|
|||||||
device_extensions
|
device_extensions
|
||||||
.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n)));
|
.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)));
|
device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n)));
|
||||||
Some(pf_client_core::video::VulkanDecodeDevice {
|
Some(pf_client_core::video::VulkanDecodeDevice {
|
||||||
get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize,
|
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 (format, hdr10_format) = pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
|
||||||
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
|
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 overlay_pipe = OverlayPipe::new(&device, format.format)?;
|
||||||
|
|
||||||
let cmd_pool = unsafe {
|
let cmd_pool = unsafe {
|
||||||
@@ -651,6 +679,8 @@ impl Presenter {
|
|||||||
format,
|
format,
|
||||||
hdr10_format,
|
hdr10_format,
|
||||||
hdr_active: false,
|
hdr_active: false,
|
||||||
|
hdr_metadata_d,
|
||||||
|
hdr_meta: None,
|
||||||
video_format: vk::Format::R8G8B8A8_UNORM,
|
video_format: vk::Format::R8G8B8A8_UNORM,
|
||||||
present_mode,
|
present_mode,
|
||||||
swapchain: vk::SwapchainKHR::null(),
|
swapchain: vk::SwapchainKHR::null(),
|
||||||
@@ -762,9 +792,74 @@ impl Presenter {
|
|||||||
images = self.images.len(),
|
images = self.images.len(),
|
||||||
"swapchain (re)created"
|
"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(())
|
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
|
/// Whether the hardware (dmabuf) path exists on this device — callers keep the
|
||||||
/// decoder on software when it doesn't.
|
/// decoder on software when it doesn't.
|
||||||
pub fn supports_dmabuf(&self) -> bool {
|
pub fn supports_dmabuf(&self) -> bool {
|
||||||
@@ -1687,8 +1782,13 @@ fn pick_formats(
|
|||||||
Ok((sdr, hdr10))
|
Ok((sdr, hdr10))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
|
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||||
/// a streaming client defaults to tear-free.
|
/// 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(
|
fn pick_present_mode(
|
||||||
surface_i: &ash::khr::surface::Instance,
|
surface_i: &ash::khr::surface::Instance,
|
||||||
pdev: vk::PhysicalDevice,
|
pdev: vk::PhysicalDevice,
|
||||||
@@ -1696,9 +1796,9 @@ fn pick_present_mode(
|
|||||||
) -> Result<vk::PresentModeKHR> {
|
) -> Result<vk::PresentModeKHR> {
|
||||||
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
|
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() {
|
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,
|
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||||
_ => vk::PresentModeKHR::FIFO,
|
_ => vk::PresentModeKHR::MAILBOX,
|
||||||
};
|
};
|
||||||
Ok(if modes.contains(&want) {
|
Ok(if modes.contains(&want) {
|
||||||
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)
|
/// 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
|
/// degrades to a plain layout transition. The matching decode-side acquire happens in
|
||||||
/// FFmpeg, keyed off the queue_family we write back after submission.
|
/// 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(
|
fn vkframe_acquire_barrier(
|
||||||
device: &ash::Device,
|
device: &ash::Device,
|
||||||
cmd: vk::CommandBuffer,
|
cmd: vk::CommandBuffer,
|
||||||
@@ -1820,7 +1931,7 @@ fn vkframe_acquire_barrier(
|
|||||||
unsafe {
|
unsafe {
|
||||||
device.cmd_pipeline_barrier(
|
device.cmd_pipeline_barrier(
|
||||||
cmd,
|
cmd,
|
||||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||||
vk::DependencyFlags::empty(),
|
vk::DependencyFlags::empty(),
|
||||||
&[],
|
&[],
|
||||||
|
|||||||
@@ -314,6 +314,11 @@ pub struct NativeClient {
|
|||||||
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
|
/// 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).
|
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
|
||||||
frames_dropped: Arc<AtomicU64>,
|
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
|
/// 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
|
/// (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
|
/// [`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 mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||||
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
let probe = Arc::new(Mutex::new(ProbeState::default()));
|
||||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
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 hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
@@ -499,6 +505,7 @@ impl NativeClient {
|
|||||||
let mode_slot_w = mode_slot.clone();
|
let mode_slot_w = mode_slot.clone();
|
||||||
let probe_w = probe.clone();
|
let probe_w = probe.clone();
|
||||||
let frames_dropped_w = frames_dropped.clone();
|
let frames_dropped_w = frames_dropped.clone();
|
||||||
|
let fec_recovered_w = fec_recovered.clone();
|
||||||
let hot_tids_w = hot_tids.clone();
|
let hot_tids_w = hot_tids.clone();
|
||||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
@@ -550,6 +557,7 @@ impl NativeClient {
|
|||||||
mode_slot: mode_slot_w,
|
mode_slot: mode_slot_w,
|
||||||
probe: probe_w,
|
probe: probe_w,
|
||||||
frames_dropped: frames_dropped_w,
|
frames_dropped: frames_dropped_w,
|
||||||
|
fec_recovered: fec_recovered_w,
|
||||||
hot_tids: hot_tids_w,
|
hot_tids: hot_tids_w,
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
@@ -592,6 +600,7 @@ impl NativeClient {
|
|||||||
quit,
|
quit,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
mode: mode_slot,
|
mode: mode_slot,
|
||||||
host_fingerprint: fingerprint,
|
host_fingerprint: fingerprint,
|
||||||
@@ -734,6 +743,14 @@ impl NativeClient {
|
|||||||
self.frames_dropped.load(Ordering::Relaxed)
|
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
|
/// 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
|
/// 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)
|
/// 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>>,
|
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
frames_dropped: Arc<AtomicU64>,
|
frames_dropped: Arc<AtomicU64>,
|
||||||
|
fec_recovered: Arc<AtomicU64>,
|
||||||
hot_tids: Arc<Mutex<Vec<i32>>>,
|
hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,6 +1042,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mode_slot,
|
mode_slot,
|
||||||
probe,
|
probe,
|
||||||
frames_dropped,
|
frames_dropped,
|
||||||
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
} = args;
|
} = args;
|
||||||
let setup = async {
|
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.
|
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||||
let st = session.stats();
|
let st = session.stats();
|
||||||
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||||
|
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
|
||||||
let probe_active = {
|
let probe_active = {
|
||||||
let mut p = pump_probe.lock().unwrap();
|
let mut p = pump_probe.lock().unwrap();
|
||||||
if p.active && !p.done {
|
if p.active && !p.done {
|
||||||
|
|||||||
@@ -1349,8 +1349,10 @@ const RICH_TOUCHPAD_EX: u8 = 0x03;
|
|||||||
/// kind decodes to `None` and is dropped).
|
/// kind decodes to `None` and is dropped).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum RichInput {
|
pub enum RichInput {
|
||||||
/// One touchpad contact. `x`/`y` are normalized `0..=65535` (the host scales to the
|
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
|
||||||
/// DualSense touchpad resolution); `active = false` lifts the finger.
|
/// 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 {
|
Touchpad {
|
||||||
pad: u8,
|
pad: u8,
|
||||||
finger: u8,
|
finger: u8,
|
||||||
@@ -1368,9 +1370,13 @@ pub enum RichInput {
|
|||||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
/// 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`:
|
/// 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.
|
/// `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`
|
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
|
||||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
|
||||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
/// (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 {
|
TouchpadEx {
|
||||||
pad: u8,
|
pad: u8,
|
||||||
surface: u8,
|
surface: u8,
|
||||||
@@ -1783,6 +1789,15 @@ pub mod endpoint {
|
|||||||
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||||
));
|
));
|
||||||
t.keep_alive_interval(Some(keep_alive));
|
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)
|
Arc::new(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,10 @@ windows = { version = "0.62", features = [
|
|||||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||||
"Win32_System_Com",
|
"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
|
# 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
|
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ impl DualSenseManager {
|
|||||||
s.touch = prev.touch;
|
s.touch = prev.touch;
|
||||||
s.gyro = prev.gyro;
|
s.gyro = prev.gyro;
|
||||||
s.accel = prev.accel;
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
self.state[idx] = s;
|
self.state[idx] = s;
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
@@ -267,50 +268,9 @@ impl DualSenseManager {
|
|||||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rich {
|
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||||
RichInput::Touchpad {
|
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
finger,
|
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
||||||
//! same as a real USB DualSense.
|
//! 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
|
// 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
|
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
||||||
@@ -125,6 +125,12 @@ pub struct DsState {
|
|||||||
pub gyro: [i16; 3],
|
pub gyro: [i16; 3],
|
||||||
pub accel: [i16; 3],
|
pub accel: [i16; 3],
|
||||||
pub touch: [Touch; 2],
|
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 {
|
impl DsState {
|
||||||
@@ -235,6 +241,88 @@ impl DsState {
|
|||||||
_ => 8,
|
_ => 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
|
/// 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[7] = seq; // seq_number (struct off 6)
|
||||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||||
r[9] = st.buttons[1]; // off 8
|
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
|
r[11] = st.buttons[3]; // off 10
|
||||||
for (i, v) in st.gyro.iter().enumerate() {
|
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
|
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
|
// 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,
|
// 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).
|
// 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 high = (data[3] as u16) << 8;
|
||||||
let low = (data[4] as u16) << 8;
|
let low = (data[4] as u16) << 8;
|
||||||
fb.rumble = Some((low, high));
|
fb.rumble = Some((low, high));
|
||||||
@@ -350,6 +443,127 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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
|
/// 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).
|
/// 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[4] = st.ry;
|
||||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
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[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[8] = st.l2; // L2 analog (z)
|
||||||
r[9] = st.r2; // R2 analog (rz)
|
r[9] = st.r2; // R2 analog (rz)
|
||||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
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
|
/// 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`]
|
/// 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).
|
/// 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) {
|
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 {
|
match rich {
|
||||||
RichInput::Touchpad { active, x, y, .. } => {
|
RichInput::Touchpad { active, x, y, .. } => {
|
||||||
self.press(btn::RPAD_TOUCH, active);
|
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_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, .. } => {
|
RichInput::Motion { gyro, accel, .. } => {
|
||||||
// The wire carries DualSense-convention units (what every client capture emits); the
|
// The wire carries DualSense-convention units (what every client capture emits); the
|
||||||
@@ -259,18 +269,18 @@ impl SteamState {
|
|||||||
y,
|
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.
|
// left pad, anything else (0 single / 2 right) = right pad.
|
||||||
if surface == 1 {
|
if surface == 1 {
|
||||||
self.press(btn::LPAD_TOUCH, touch);
|
self.press(btn::LPAD_TOUCH, touch);
|
||||||
self.press(btn::LPAD_CLICK, click);
|
self.press(btn::LPAD_CLICK, click);
|
||||||
self.lpad_x = x;
|
self.lpad_x = x;
|
||||||
self.lpad_y = y;
|
self.lpad_y = flip_y(y);
|
||||||
} else {
|
} else {
|
||||||
self.press(btn::RPAD_TOUCH, touch);
|
self.press(btn::RPAD_TOUCH, touch);
|
||||||
self.press(btn::RPAD_CLICK, click);
|
self.press(btn::RPAD_CLICK, click);
|
||||||
self.rpad_x = x;
|
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_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||||
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
||||||
assert_eq!(s.rpad_y, -32768); // 0-32768
|
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,
|
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||||
s.apply_rich(RichInput::Motion {
|
s.apply_rich(RichInput::Motion {
|
||||||
pad: 0,
|
pad: 0,
|
||||||
gyro: [1000, -2000, 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
|
/// 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]
|
#[test]
|
||||||
fn back_buttons_and_dual_trackpad_mapping() {
|
fn back_buttons_and_dual_trackpad_mapping() {
|
||||||
let s = SteamState::from_gamepad(
|
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_TOUCH, 0);
|
||||||
assert_ne!(s.buttons & btn::LPAD_CLICK, 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 {
|
s.apply_rich(RichInput::TouchpadEx {
|
||||||
pad: 0,
|
pad: 0,
|
||||||
surface: 2,
|
surface: 2,
|
||||||
@@ -613,7 +624,20 @@ mod tests {
|
|||||||
pressure: 0,
|
pressure: 0,
|
||||||
});
|
});
|
||||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 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*
|
/// 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.touch = prev.touch;
|
||||||
s.gyro = prev.gyro;
|
s.gyro = prev.gyro;
|
||||||
s.accel = prev.accel;
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
self.state[idx] = s;
|
self.state[idx] = s;
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
@@ -458,46 +459,9 @@ impl DualSenseWindowsManager {
|
|||||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rich {
|
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||||
RichInput::Touchpad {
|
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
finger,
|
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ impl DualShock4WindowsManager {
|
|||||||
s.touch = prev.touch;
|
s.touch = prev.touch;
|
||||||
s.gyro = prev.gyro;
|
s.gyro = prev.gyro;
|
||||||
s.accel = prev.accel;
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
self.state[idx] = s;
|
self.state[idx] = s;
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
@@ -224,46 +225,9 @@ impl DualShock4WindowsManager {
|
|||||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match rich {
|
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||||
RichInput::Touchpad {
|
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
finger,
|
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ mod discovery;
|
|||||||
mod wol;
|
mod wol;
|
||||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
// 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).
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/dmabuf_fence.rs"]
|
#[path = "linux/dmabuf_fence.rs"]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
@@ -111,6 +114,35 @@ fn main() {
|
|||||||
.init();
|
.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() {
|
if let Err(e) = real_main() {
|
||||||
tracing::error!("{e:#}");
|
tracing::error!("{e:#}");
|
||||||
std::process::exit(1);
|
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,
|
// 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
|
// 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.
|
// (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 input_handle = {
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
let gamepad = welcome.gamepad;
|
let gamepad = welcome.gamepad;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk1-input".into())
|
.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")?
|
.context("spawn input thread")?
|
||||||
};
|
};
|
||||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
// 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());
|
let _ = mic_tx.try_send(opus.to_vec());
|
||||||
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
|
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
|
||||||
rich_count += 1;
|
rich_count += 1;
|
||||||
if rich_tx.send(rich).is_err() {
|
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if let Some(mut ev) = InputEvent::decode(&d) {
|
} 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;
|
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;
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
|
||||||
/// events — rumble on the universal datagram plane, DualSense LED/trigger feedback on the
|
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
|
||||||
/// HID-output plane. The gamepads are created and torn down with the session; the
|
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||||
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
|
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
|
||||||
|
/// across sessions.
|
||||||
fn input_thread(
|
fn input_thread(
|
||||||
rx: std::sync::mpsc::Receiver<InputEvent>,
|
rx: std::sync::mpsc::Receiver<ClientInput>,
|
||||||
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
|
|
||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
) {
|
) {
|
||||||
let mut pads = PadBackend::select(gamepad);
|
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_state = [PadState::default(); MAX_WIRE_PADS];
|
||||||
let mut pad_mask = 0u16;
|
let mut pad_mask = 0u16;
|
||||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
// 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();
|
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
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 => {
|
InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||||
// A bad index / unknown axis just doesn't update a pad — fall through (no
|
// 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
|
// `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::Timeout) => {}
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
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
|
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
|
||||||
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
|
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
|
||||||
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
|
// 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,
|
// 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).
|
// so it folds to Xbox360 for now (Windows Steam devices are M7).
|
||||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
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,
|
_ => 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
|
// 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).
|
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
|
||||||
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
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
|
// 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
|
// 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
|
// 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(XboxOne, None, true, false), XboxOne);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||||
|
|
||||||
|
// Steam Deck: native on Linux; 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]
|
#[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
|
/// 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.
|
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
|
||||||
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
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 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
|
/// 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
|
/// 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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
|
// Windows leases in the manager (its own linger); its deliberate-quit skip is wired through
|
||||||
// through there yet, so the flag is accepted but unused off Linux.
|
// `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;
|
let _ = quit;
|
||||||
vd.create(mode)
|
vd.create(mode)
|
||||||
}
|
}
|
||||||
@@ -602,18 +603,25 @@ mod linux {
|
|||||||
Ok(output_for(node_id, preferred_mode, gen, quit, false))
|
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
|
/// 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*
|
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
|
||||||
/// the lock is released.
|
/// the lock is released.
|
||||||
fn release(gen: u64, force_immediate: bool) {
|
fn release(gen: u64, force_immediate: bool) {
|
||||||
let Some(r) = REG.get() else { return };
|
let Some(r) = REG.get() else { return };
|
||||||
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
|
let linger = effective_linger(force_immediate, linger());
|
||||||
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
|
|
||||||
let linger = if force_immediate {
|
|
||||||
Linger::Immediate
|
|
||||||
} else {
|
|
||||||
linger()
|
|
||||||
};
|
|
||||||
let (torn_down, restore) = {
|
let (torn_down, restore) = {
|
||||||
let mut es = r.entries.lock().unwrap();
|
let mut es = r.entries.lock().unwrap();
|
||||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
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))
|
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]
|
#[test]
|
||||||
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
|
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
|
||||||
let ran = Arc::new(AtomicBool::new(false));
|
let ran = Arc::new(AtomicBool::new(false));
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ impl VirtualDisplayManager {
|
|||||||
&'static self,
|
&'static self,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
client_fp: Option<[u8; 32]>,
|
client_fp: Option<[u8; 32]>,
|
||||||
|
quit: Option<Arc<AtomicBool>>,
|
||||||
) -> Result<VirtualOutput> {
|
) -> Result<VirtualOutput> {
|
||||||
self.ensure_linger_timer();
|
self.ensure_linger_timer();
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
@@ -474,7 +475,7 @@ impl VirtualDisplayManager {
|
|||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"virtual monitor reused (concurrent / reconfigure session)"
|
"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
|
// 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"),
|
MgrState::Active { .. } => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
let out = self.output_for(&mon);
|
let out = self.output_for(&mon, quit);
|
||||||
*state = MgrState::Active { mon, refs: 1 };
|
*state = MgrState::Active { mon, refs: 1 };
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
|
/// 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 {
|
VirtualOutput {
|
||||||
node_id: 0,
|
node_id: 0,
|
||||||
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
|
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
|
||||||
@@ -530,6 +532,7 @@ impl VirtualDisplayManager {
|
|||||||
keepalive: Box::new(MonitorLease {
|
keepalive: Box::new(MonitorLease {
|
||||||
mgr: self,
|
mgr: self,
|
||||||
gen: mon.gen,
|
gen: mon.gen,
|
||||||
|
quit,
|
||||||
}),
|
}),
|
||||||
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
|
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
|
||||||
// (which delegates to it via `vd.create`) treats it as Owned.
|
// (which delegates to it via `vd.create`) treats it as Owned.
|
||||||
@@ -760,6 +763,28 @@ impl VirtualDisplayManager {
|
|||||||
/// # Safety
|
/// # Safety
|
||||||
/// `dev` must be the live control handle.
|
/// `dev` must be the live control handle.
|
||||||
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
|
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);
|
mon.stop.store(true, Ordering::Relaxed);
|
||||||
if let Some(j) = mon.pinger.take() {
|
if let Some(j) = mon.pinger.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
@@ -784,12 +809,20 @@ impl VirtualDisplayManager {
|
|||||||
"virtual-display monitor removed"
|
"virtual-display monitor removed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
|
/// 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
|
/// LINGERs before teardown — unless `quit_now` (the client closed with the QUIT code, a user
|
||||||
/// no-op, so it can't tear down the CURRENT monitor.
|
/// "stop"), which tears the monitor down IMMEDIATELY instead of lingering. That both restores
|
||||||
fn release(&self, gen: u64) {
|
/// 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 mut state = self.state.lock().unwrap();
|
||||||
let stale = match &*state {
|
let stale = match &*state {
|
||||||
MgrState::Active { mon, .. }
|
MgrState::Active { mon, .. }
|
||||||
@@ -805,14 +838,41 @@ impl VirtualDisplayManager {
|
|||||||
mon,
|
mon,
|
||||||
refs: refs - 1,
|
refs: refs - 1,
|
||||||
},
|
},
|
||||||
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
|
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever` —
|
||||||
// else linger for the policy window before the timer tears it down.
|
// 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() => {
|
MgrState::Active { mon, .. } if keep_alive_forever() => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
|
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
|
||||||
);
|
);
|
||||||
MgrState::Pinned { mon }
|
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, .. } => {
|
MgrState::Active { mon, .. } => {
|
||||||
let ms = linger_ms();
|
let ms = linger_ms();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -948,11 +1008,17 @@ impl VirtualDisplayManager {
|
|||||||
struct MonitorLease {
|
struct MonitorLease {
|
||||||
mgr: &'static VirtualDisplayManager,
|
mgr: &'static VirtualDisplayManager,
|
||||||
gen: u64,
|
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 {
|
impl Drop for MonitorLease {
|
||||||
fn drop(&mut self) {
|
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).
|
/// 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`.
|
/// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
|
||||||
client_fp: Option<[u8; 32]>,
|
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 {
|
impl PfVdisplayDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
|
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;
|
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> {
|
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"
|
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||||
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
# /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"
|
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)
|
# 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"
|
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.
|
# 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
|
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 || :
|
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
|
||||||
done
|
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):
|
# 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.
|
# 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" ] \
|
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) ----------------------------------
|
# --- file layout (matches the RPM %install) ----------------------------------
|
||||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
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
|
# 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.
|
# 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"
|
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).
|
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
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
|
# 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.
|
# 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
|
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-host
|
||||||
%{_bindir}/punktfunk-tray
|
%{_bindir}/punktfunk-tray
|
||||||
%{_udevrulesdir}/60-punktfunk.rules
|
%{_udevrulesdir}/60-punktfunk.rules
|
||||||
|
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||||
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
|
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
|
||||||
|
|||||||
@@ -10,3 +10,10 @@
|
|||||||
# sudo udevadm control --reload-rules && sudo udevadm trigger
|
# sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
|
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||||
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
|
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
|
||||||
|
|
||||||
|
# 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
|
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.
|
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
|
## Typical flow after pulling new code
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git pull
|
git pull
|
||||||
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-host.ps1
|
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
|
||||||
powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.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 }
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
Start-Sleep 2
|
Start-Sleep 2
|
||||||
& schtasks /run /tn $task | Out-Null
|
& schtasks /run /tn $task | Out-Null
|
||||||
Start-Sleep 5
|
|
||||||
try {
|
# web-run.cmd serves HTTPS-only (PUNKTFUNK_UI_SECURE=1, the host's own cert) - probe with curl.exe
|
||||||
$r = Invoke-WebRequest 'http://127.0.0.1:47992/login' -UseBasicParsing -TimeoutSec 10
|
# (-k for the self-signed cert; Invoke-WebRequest under Windows PowerShell 5.1, which this script
|
||||||
Write-Host "DONE - web /login -> HTTP $($r.StatusCode)"
|
# runs under, has no -SkipCertificateCheck), retrying while the task/bun cold-starts.
|
||||||
} catch { Write-Warning "web restarted but /login check failed: $($_.Exception.Message)" }
|
$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",
|
"name": "punktfunk-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-router": "^1.121.0",
|
"@tanstack/react-router": "^1.170.17",
|
||||||
"@tanstack/react-start": "^1.121.0",
|
"@tanstack/react-start": "^1.168.27",
|
||||||
"@unom/style": "^0.4.4",
|
"@unom/style": "^0.4.4",
|
||||||
"@unom/ui": "^0.8.16",
|
"@unom/ui": "^0.8.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.42.2",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.7",
|
||||||
"recharts": "^3.9.0",
|
"recharts": "^3.9.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.1",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.1",
|
"@biomejs/biome": "^2.5.2",
|
||||||
"@inlang/paraglide-js": "^2.0.0",
|
"@inlang/paraglide-js": "^2.20.2",
|
||||||
"@storybook/react-vite": "^10.4.6",
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"orval": "^8.16.0",
|
"orval": "^8.20.0",
|
||||||
"playwright": "^1.61.1",
|
"playwright": "^1.61.1",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.4.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.2",
|
||||||
"tw-animate-css": "^1.2.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.5",
|
"vite": "^7.3.6",
|
||||||
"vite-tsconfig-paths": "^5.1.0",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user