Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7cb75a48a | |||
| 08694b4026 | |||
| 38b9f310e2 | |||
| 7b25868a19 | |||
| 47d22b6082 | |||
| c86da1a1ff |
+2
-1
@@ -26,7 +26,8 @@ exclude = [
|
|||||||
"clients/android/native/vendor/ndk",
|
"clients/android/native/vendor/ndk",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public
|
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||||
|
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
|
||||||
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
|
||||||
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
|
||||||
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ object Gamepad {
|
|||||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
||||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
||||||
* sticks on the host (which has no client-side held-state knowledge).
|
* sticks on the host (which has no client-side held-state knowledge).
|
||||||
|
*
|
||||||
|
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
||||||
|
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
||||||
|
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
||||||
|
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
||||||
|
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
||||||
*/
|
*/
|
||||||
class AxisMapper(private val handle: Long) {
|
class AxisMapper(private val handle: Long) {
|
||||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||||
@@ -182,10 +188,29 @@ object Gamepad {
|
|||||||
private var hatX = 0 // -1 / 0 / +1
|
private var hatX = 0 // -1 / 0 / +1
|
||||||
private var hatY = 0
|
private var hatY = 0
|
||||||
|
|
||||||
|
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
||||||
|
private var deviceId = -1
|
||||||
|
|
||||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
||||||
fun onMotion(event: MotionEvent): Boolean {
|
fun onMotion(event: MotionEvent): Boolean {
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||||
|
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
||||||
|
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
||||||
|
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
||||||
|
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
||||||
|
// class doc for the held-trigger flap this caused).
|
||||||
|
val dev = event.device ?: return false
|
||||||
|
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
||||||
|
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
||||||
|
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
||||||
|
if (deviceId != event.deviceId) {
|
||||||
|
if (deviceId != -1) {
|
||||||
|
if (InputDevice.getDevice(deviceId) != null) return false
|
||||||
|
reset() // the pinned pad is gone — lift its held state before adopting
|
||||||
|
}
|
||||||
|
deviceId = event.deviceId
|
||||||
|
}
|
||||||
|
|
||||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||||
@@ -193,9 +218,27 @@ object Gamepad {
|
|||||||
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
|
||||||
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
|
||||||
|
|
||||||
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255.
|
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
|
||||||
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)))
|
// with max, the same fold as the Controllers screen probe, so a pad that reports
|
||||||
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)))
|
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
|
||||||
|
sendAxis(
|
||||||
|
AXIS_LT,
|
||||||
|
trigger(
|
||||||
|
maxOf(
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_BRAKE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sendAxis(
|
||||||
|
AXIS_RT,
|
||||||
|
trigger(
|
||||||
|
maxOf(
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_GAS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// HAT → dpad button transitions (track previous, emit only the deltas).
|
// HAT → dpad button transitions (track previous, emit only the deltas).
|
||||||
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
|
||||||
@@ -237,10 +280,5 @@ object Gamepad {
|
|||||||
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
|
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
|
||||||
|
|
||||||
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
|
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
|
||||||
|
|
||||||
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
|
|
||||||
val va = e.getAxisValue(a)
|
|
||||||
return if (va != 0f) va else e.getAxisValue(b)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ impl InputQueue {
|
|||||||
looper.ptr().as_ptr(),
|
looper.ptr().as_ptr(),
|
||||||
id,
|
id,
|
||||||
None,
|
None,
|
||||||
std::ptr::null_mut(),
|
ptr::null_mut(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ use std::{
|
|||||||
slice,
|
slice,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::media_error::{MediaError, Result};
|
use crate::media_error::MediaError;
|
||||||
|
// `Result` is only referenced by the api-level-29 methods below; an ungated import warns
|
||||||
|
// (unused_imports) on every default-feature build.
|
||||||
|
#[cfg(feature = "api-level-29")]
|
||||||
|
use crate::media_error::Result;
|
||||||
|
|
||||||
/// A native [`AMediaFormat *`]
|
/// A native [`AMediaFormat *`]
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||||||
|
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
|
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
|
||||||
|
private var statsVerbosity: StatsVerbosity {
|
||||||
|
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||||
|
}
|
||||||
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||||
private var preferredCodecByte: UInt8 {
|
private var preferredCodecByte: UInt8 {
|
||||||
switch codec {
|
switch codec {
|
||||||
@@ -393,8 +400,10 @@ struct ContentView: View {
|
|||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
if captureEnabled && hudEnabled {
|
if captureEnabled && statsVerbosity != .off {
|
||||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
StreamHUDView(
|
||||||
|
model: model, connection: conn, placement: placement,
|
||||||
|
verbosity: statsVerbosity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -422,17 +431,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// material disc (like the HUD) so the glyph stays legible over a bright frame
|
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||||
// — this is the sole touch disconnect path when stats are off.
|
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||||
|
// sole touch disconnect path in those tiers.
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled && !hudEnabled {
|
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Sole touch exit when the HUD is off — a floating glass disc
|
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||||
// over the frame (26+, material fallback). interactive: the disc
|
// over the frame (26+, material fallback). interactive: the disc
|
||||||
// IS the tap target, so the glass reacts to press.
|
// IS the tap target, so the glass reacts to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||||
// the menu covers the released state and discoverability. The stats toggle just flips the
|
// the menu covers the released state and discoverability. The stats item cycles the shared
|
||||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
// `statsVerbosity` tier (off → compact → normal → detailed → off); ContentView reads the same
|
||||||
|
// @AppStorage and reacts.
|
||||||
//
|
//
|
||||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
||||||
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
||||||
@@ -36,12 +37,16 @@ extension FocusedValues {
|
|||||||
|
|
||||||
struct StreamCommands: Commands {
|
struct StreamCommands: Commands {
|
||||||
@FocusedValue(\.sessionFocus) private var session
|
@FocusedValue(\.sessionFocus) private var session
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
|
||||||
|
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
|
|
||||||
var body: some Commands {
|
var body: some Commands {
|
||||||
CommandMenu("Stream") {
|
CommandMenu("Stream") {
|
||||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
Button("Cycle Statistics") {
|
||||||
hudEnabled.toggle()
|
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||||
|
statsVerbosityRaw = current.next().rawValue
|
||||||
}
|
}
|
||||||
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||||
// Reaches the key window's stream view via NotificationCenter — capture is view
|
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
|
||||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
|
||||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
|
||||||
// hint, and disconnect.
|
// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss
|
||||||
|
// counter, the platform input hint, and disconnect;
|
||||||
|
// * detailed — everything normal has plus the stage equation line(s) under the headline.
|
||||||
|
// `.off` never reaches this view (ContentView gates the overlay on the tier).
|
||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -10,8 +13,56 @@ struct StreamHUDView: View {
|
|||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
let connection: PunktfunkConnection
|
let connection: PunktfunkConnection
|
||||||
var placement: HUDPlacement = .topTrailing
|
var placement: HUDPlacement = .topTrailing
|
||||||
|
let verbosity: StatsVerbosity
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// .off is gated upstream (ContentView only mounts the HUD when the tier is on) —
|
||||||
|
// render nothing if it ever slips through.
|
||||||
|
if verbosity == .compact {
|
||||||
|
compactPill
|
||||||
|
} else if verbosity != .off {
|
||||||
|
fullHUD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact tier
|
||||||
|
|
||||||
|
/// One line on the glass pill: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is
|
||||||
|
/// the best available latency headline (stage-2 end-to-end, else the stage-1
|
||||||
|
/// capture→received) and is omitted until either is valid. Loss appends in the same quiet
|
||||||
|
/// styling the full HUD's lost line uses.
|
||||||
|
private var compactPill: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(compactLine)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
if model.lostFrames > 0 {
|
||||||
|
Text("· lost \(model.lostFrames)")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactLine: String {
|
||||||
|
var parts = ["\(model.fps) fps"]
|
||||||
|
if model.endToEndValid {
|
||||||
|
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||||
|
} else if model.hostNetworkValid {
|
||||||
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
|
}
|
||||||
|
parts.append(String(format: "%.1f Mb/s", model.mbps))
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Normal / detailed tiers
|
||||||
|
|
||||||
|
private var fullHUD: some View {
|
||||||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -26,11 +77,11 @@ struct StreamHUDView: View {
|
|||||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// they only approximately sum to the directly-measured total). With a host
|
// (per-window p50s — they only approximately sum to the directly-measured
|
||||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||||
// (phase 2); an old host keeps the combined term.
|
// into host + network (phase 2); an old host keeps the combined term.
|
||||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
@@ -45,11 +96,12 @@ struct StreamHUDView: View {
|
|||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||||
// only equation line; without it, host+network IS the whole measured interval.
|
// only equation line (detailed tier); without it, host+network IS the whole
|
||||||
|
// measured interval.
|
||||||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if model.splitValid {
|
if verbosity == .detailed && model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||||
|
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@@ -271,10 +274,12 @@ struct GamepadSettingsView: View {
|
|||||||
options: SettingsOptions.padTypes, current: gamepadType
|
options: SettingsOptions.padTypes, current: gamepadType
|
||||||
) { gamepadType = $0 },
|
) { gamepadType = $0 },
|
||||||
|
|
||||||
toggleRow(
|
choiceRow(
|
||||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||||
value: $hudEnabled),
|
+ "Detailed adds the latency stage breakdown.",
|
||||||
|
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||||
|
) { statsVerbosityRaw = $0 },
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||||
detail: "Which corner the statistics overlay sits in.",
|
detail: "Which corner the statistics overlay sits in.",
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
static let statsVerbosities: [(label: String, tag: String)] =
|
||||||
|
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||||
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
||||||
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
||||||
|
|||||||
@@ -337,28 +337,32 @@ extension SettingsView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
|
||||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
// stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the
|
||||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
// pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
|
||||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
// Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it
|
||||||
|
// freezes hard on a lost HEVC reference.
|
||||||
@ViewBuilder var presenterSection: some View {
|
@ViewBuilder var presenterSection: some View {
|
||||||
#if DEBUG
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Presenter", selection: $presenter) {
|
Picker("Presenter", selection: $presenter) {
|
||||||
Text("Stage 2 (default)").tag("stage2")
|
Text("Stage 2 (default)").tag("stage2")
|
||||||
|
Text("Stage 3 (experimental)").tag("stage3")
|
||||||
|
#if DEBUG
|
||||||
Text("Stage 1 (debug)").tag("stage1")
|
Text("Stage 1 (debug)").tag("stage1")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Video presenter · debug")
|
Text("Video presenter")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency "
|
Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
|
||||||
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video "
|
+ "running near the stream's frame rate, queued frames can add two to three "
|
||||||
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a "
|
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
|
||||||
+ "debug fallback only. Applies from the next session.")
|
+ "to the display — at most one undisplayed frame in flight, always the freshest, "
|
||||||
|
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
|
||||||
|
+ "display time to compare. Applies from the next session.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder var hdrSection: some View {
|
@ViewBuilder var hdrSection: some View {
|
||||||
@@ -385,13 +389,17 @@ extension SettingsView {
|
|||||||
|
|
||||||
@ViewBuilder var statisticsSection: some View {
|
@ViewBuilder var statisticsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
Picker("Position", selection: $hudPlacement) {
|
Picker("Position", selection: $hudPlacement) {
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
Text(placement.label).tag(placement.rawValue)
|
Text(placement.label).tag(placement.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!hudEnabled)
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Statistics")
|
Text("Statistics")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ extension SettingsView {
|
|||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsFooter: String {
|
static var statisticsFooter: String {
|
||||||
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||||
+ "corner while streaming."
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||||
#if os(macOS) || os(iOS)
|
+ "breakdown."
|
||||||
return base + " Toggle it any time with ⌃⌥⇧S."
|
#if os(macOS)
|
||||||
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||||
|
#elseif os(iOS)
|
||||||
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||||
|
+ "any time."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||||
|
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -282,6 +284,19 @@ struct SettingsView: View {
|
|||||||
("4K @ 60", "3840x2160x60"),
|
("4K @ 60", "3840x2160x60"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
|
||||||
|
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||||
|
private static var presenterOptions: [(label: String, tag: String)] {
|
||||||
|
var options: [(label: String, tag: String)] = [
|
||||||
|
("Stage 2 (default)", "stage2"),
|
||||||
|
("Stage 3 (experimental)", "stage3"),
|
||||||
|
]
|
||||||
|
#if DEBUG
|
||||||
|
options.append(("Stage 1 (debug)", "stage1"))
|
||||||
|
#endif
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
private var modeTag: Binding<String> {
|
private var modeTag: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { "\(width)x\(height)x\(hz)" },
|
get: { "\(width)x\(height)x\(hz)" },
|
||||||
@@ -294,10 +309,6 @@ struct SettingsView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hudEnabledTag: Binding<String> {
|
|
||||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hdrEnabledTag: Binding<String> {
|
private var hdrEnabledTag: Binding<String> {
|
||||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
@@ -334,12 +345,10 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Compositor", options: SettingsOptions.compositors,
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
selection: $compositor)
|
selection: $compositor)
|
||||||
#if DEBUG
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter (debug)",
|
title: "Presenter",
|
||||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
options: Self.presenterOptions,
|
||||||
selection: $presenter)
|
selection: $presenter)
|
||||||
#endif
|
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||||
@@ -352,7 +361,7 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||||||
selection: $hudPlacement)
|
selection: $hudPlacement)
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ public final class InputCapture {
|
|||||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats
|
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||||
/// overlay. Main queue.
|
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||||
public var onReleaseCapture: (() -> Void)?
|
public var onReleaseCapture: (() -> Void)?
|
||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onToggleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||||
@@ -246,7 +246,7 @@ public final class InputCapture {
|
|||||||
return nil
|
return nil
|
||||||
case 1 /* S */:
|
case 1 /* S */:
|
||||||
self.suppressedVK = 0x53
|
self.suppressedVK = 0x53
|
||||||
self.onToggleStats?()
|
self.onCycleStats?()
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||||
|
// (off → compact → normal → detailed, matching Android):
|
||||||
//
|
//
|
||||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||||
@@ -164,7 +165,7 @@ final class TouchMouse {
|
|||||||
} else if !moved {
|
} else if !moved {
|
||||||
switch maxFingers {
|
switch maxFingers {
|
||||||
case 3...:
|
case 3...:
|
||||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
Self.cycleStats() // in-stream stats-tier cycle, same as Android
|
||||||
case 2: // two-finger tap → right click
|
case 2: // two-finger tap → right click
|
||||||
send?(.mouseButton(Button.right, down: true))
|
send?(.mouseButton(Button.right, down: true))
|
||||||
send?(.mouseButton(Button.right, down: false))
|
send?(.mouseButton(Button.right, down: false))
|
||||||
@@ -274,12 +275,11 @@ final class TouchMouse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
/// Three-finger tap cycles the stats overlay tiers (off → compact → normal → detailed) —
|
||||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
/// through the shared `statsVerbosity` default, which the app's HUD views observe via
|
||||||
private static func toggleHUD() {
|
/// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
|
||||||
let defaults = UserDefaults.standard
|
private static func cycleStats() {
|
||||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ public enum DefaultsKey {
|
|||||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
|
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||||
|
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||||
|
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||||
|
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||||
|
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
/// macOS: V-Sync the stream's presents — each decoded frame flips on the next display vsync
|
||||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
@@ -67,9 +72,15 @@ public enum DefaultsKey {
|
|||||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
/// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
|
||||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
|
/// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
|
||||||
|
/// written (absent-or-true → .normal, explicit false → .off). Never written anymore.
|
||||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||||
|
/// The statistics overlay tier — a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
|
||||||
|
/// "detailed"). Absent → migrated from the legacy `hudEnabled` bool (see above). Cycle it
|
||||||
|
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
|
||||||
|
/// keyboard) or a three-finger tap (touch), matching the Android client.
|
||||||
|
public static let statsVerbosity = "punktfunk.statsVerbosity"
|
||||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics
|
||||||
|
// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) →
|
||||||
|
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
|
||||||
|
// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with
|
||||||
|
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
|
||||||
|
//
|
||||||
|
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
|
||||||
|
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||||
|
/// rename the cases freely, never the strings.
|
||||||
|
public enum StatsVerbosity: String, CaseIterable, Sendable {
|
||||||
|
case off, compact, normal, detailed
|
||||||
|
|
||||||
|
/// User-facing tier label (Settings pickers, the gamepad settings row).
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "Off"
|
||||||
|
case .compact: return "Compact"
|
||||||
|
case .normal: return "Normal"
|
||||||
|
case .detailed: return "Detailed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) —
|
||||||
|
/// the ⌃⌥⇧S / three-finger-tap order, same as Android.
|
||||||
|
public func next() -> StatsVerbosity {
|
||||||
|
switch self {
|
||||||
|
case .off: return .compact
|
||||||
|
case .compact: return .normal
|
||||||
|
case .normal: return .detailed
|
||||||
|
case .detailed: return .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
|
||||||
|
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal`
|
||||||
|
/// (the old "on" look minus the equation lines), explicit false → `.off`.
|
||||||
|
public static var current: StatsVerbosity {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
|
||||||
|
let tier = StatsVerbosity(rawValue: raw) {
|
||||||
|
return tier
|
||||||
|
}
|
||||||
|
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
|
||||||
|
return .off
|
||||||
|
}
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
|
||||||
|
/// same key via @AppStorage).
|
||||||
|
public static func store(_ tier: StatsVerbosity) {
|
||||||
|
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,30 @@ public final class DisplayLinkProxy: NSObject {
|
|||||||
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||||
|
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||||
|
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||||
|
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||||
|
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||||
|
enum PresenterChoice: Equatable {
|
||||||
|
case stage1
|
||||||
|
case stage2
|
||||||
|
case stage3
|
||||||
|
|
||||||
|
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||||
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
|
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
|
||||||
|
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
|
||||||
|
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||||
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
|
switch raw {
|
||||||
|
case "stage1": return allowStage1 ? .stage1 : .stage2
|
||||||
|
case "stage3": return .stage3
|
||||||
|
default: return .stage2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class SessionPresenter {
|
final class SessionPresenter {
|
||||||
private var pump: StreamPump?
|
private var pump: StreamPump?
|
||||||
private var stage2: Stage2Pipeline?
|
private var stage2: Stage2Pipeline?
|
||||||
@@ -50,18 +74,24 @@ final class SessionPresenter {
|
|||||||
|
|
||||||
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
// Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
|
||||||
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
|
||||||
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is
|
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
|
||||||
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1
|
// the same pipeline with glass-gated present pacing (the settings picker's live A/B — see
|
||||||
// pump below stays the automatic fallback if Metal is missing).
|
// PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
|
||||||
|
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1"
|
let allowStage1 = true
|
||||||
#else
|
#else
|
||||||
let forceStage1 = false
|
let allowStage1 = false
|
||||||
#endif
|
#endif
|
||||||
if !forceStage1,
|
let choice = PresenterChoice.resolve(
|
||||||
|
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||||
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
|
allowStage1: allowStage1)
|
||||||
|
if choice != .stage1,
|
||||||
let pipeline = Stage2Pipeline(
|
let pipeline = Stage2Pipeline(
|
||||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter) {
|
displayMeter: displayMeter,
|
||||||
|
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
// – V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
|
||||||
// period ahead by construction, falling back to immediate when the link data is stale — a
|
// period ahead by construction, falling back to immediate when the link data is stale — a
|
||||||
// schedule can never sit far in the future holding drawables hostage.
|
// schedule can never sit far in the future holding drawables hostage.
|
||||||
|
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||||
|
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||||
|
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
|
||||||
|
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||||
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
// • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
|
||||||
//
|
//
|
||||||
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
// The render thread also stamps the unified latency stages (end-to-end capture→on-glass + decode and
|
||||||
@@ -98,6 +102,79 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode
|
||||||
|
/// half, same newest-wins ring; only the present cadence differs.
|
||||||
|
///
|
||||||
|
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
|
||||||
|
/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per
|
||||||
|
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
|
||||||
|
/// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY:
|
||||||
|
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with
|
||||||
|
/// arrivals and latches then running at the same rate — it never drains. Every later frame rides
|
||||||
|
/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and
|
||||||
|
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||||
|
/// "fixed-interval" jitter reports).
|
||||||
|
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
|
||||||
|
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
|
||||||
|
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
|
||||||
|
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||||
|
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||||
|
/// explicit, correct frame drops.
|
||||||
|
public enum PresentPacing: Sendable {
|
||||||
|
case arrival
|
||||||
|
case glass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
|
||||||
|
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
|
||||||
|
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
|
||||||
|
/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's
|
||||||
|
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
|
||||||
|
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
|
||||||
|
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the
|
||||||
|
/// releaser runs on a Metal callback thread.
|
||||||
|
final class PresentGate: @unchecked Sendable {
|
||||||
|
/// How long one pending present may hold the gate before it's presumed lost.
|
||||||
|
static let staleAfter: CFTimeInterval = 0.1
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pending = false
|
||||||
|
private var armedAt: CFTimeInterval = 0
|
||||||
|
private var forced = 0
|
||||||
|
|
||||||
|
/// Arm the gate for one present. False = a present is already in flight (and not stale) —
|
||||||
|
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
|
||||||
|
/// display-link tick) retries with the freshest frame then.
|
||||||
|
func tryAcquire(now: CFTimeInterval) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if pending {
|
||||||
|
guard now - armedAt > Self.staleAfter else { return false }
|
||||||
|
forced += 1 // presumed-lost present — reopen rather than stall the stream
|
||||||
|
}
|
||||||
|
pending = true
|
||||||
|
armedAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-flight present reached glass (or was dropped, or its render failed before a present
|
||||||
|
/// was registered) — reopen. Idempotent: a late stale-path double-release is harmless.
|
||||||
|
func release() {
|
||||||
|
lock.lock()
|
||||||
|
pending = false
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take-and-reset the force-open count (PUNKTFUNK_PRESENT_DEBUG's `forced` stat).
|
||||||
|
func drainForced() -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let n = forced
|
||||||
|
forced = 0
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
||||||
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
||||||
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||||
@@ -105,22 +182,39 @@ private final class VsyncClock: @unchecked Sendable {
|
|||||||
private final class PresentDebugStats: @unchecked Sendable {
|
private final class PresentDebugStats: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var last = CACurrentMediaTime()
|
private var last = CACurrentMediaTime()
|
||||||
private var ok = 0, failed = 0, empty = 0, dropped = 0
|
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
|
||||||
private var maxRenderMs = 0.0
|
private var maxRenderMs = 0.0
|
||||||
private var lastGlassNs: Int64 = 0
|
private var lastGlassNs: Int64 = 0
|
||||||
private var glassDeltasMs: [Double] = []
|
private var glassDeltasMs: [Double] = []
|
||||||
|
/// Presented-but-not-yet-on-glass drawables right now / the window's peak — the direct
|
||||||
|
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
|
||||||
|
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
|
||||||
|
private var inFlight = 0
|
||||||
|
private var maxInFlight = 0
|
||||||
|
|
||||||
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||||
|
|
||||||
|
/// A wake that found the stage-3 gate closed (a present still in flight) — the frame stays in
|
||||||
|
/// the ring for the handler's re-signal. Includes display-link ticks while gated; a high count
|
||||||
|
/// is normal, it just shows the gate working.
|
||||||
|
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
|
||||||
|
|
||||||
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
func renderReturned(ok rendered: Bool, tookMs: Double) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if rendered { ok += 1 } else { failed += 1 }
|
if rendered {
|
||||||
|
ok += 1
|
||||||
|
inFlight += 1
|
||||||
|
maxInFlight = max(maxInFlight, inFlight)
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
maxRenderMs = max(maxRenderMs, tookMs)
|
maxRenderMs = max(maxRenderMs, tookMs)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presented(atNs: Int64?) {
|
func presented(atNs: Int64?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||||
if let atNs {
|
if let atNs {
|
||||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||||
lastGlassNs = atNs
|
lastGlassNs = atNs
|
||||||
@@ -130,7 +224,7 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfDue(ring: ReadyRing) {
|
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
guard now - last >= 1 else { lock.unlock(); return }
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
@@ -139,12 +233,15 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
let deltas = glassDeltasMs.sorted()
|
let deltas = glassDeltasMs.sorted()
|
||||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
let dMax = deltas.last ?? 0
|
let dMax = deltas.last ?? 0
|
||||||
|
let inflightMax = maxInFlight
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d "
|
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||||
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d",
|
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||||
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count)
|
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||||
ok = 0; failed = 0; empty = 0; dropped = 0
|
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||||
|
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||||
maxRenderMs = 0
|
maxRenderMs = 0
|
||||||
|
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
print(line)
|
print(line)
|
||||||
@@ -156,6 +253,9 @@ public final class Stage2Pipeline {
|
|||||||
private let ring = ReadyRing()
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
|
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||||
|
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||||
|
private let pacing: PresentPacing
|
||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
private let recovery = KeyframeRecovery()
|
private let recovery = KeyframeRecovery()
|
||||||
@@ -190,14 +290,17 @@ public final class Stage2Pipeline {
|
|||||||
/// (received→decoded); `displayMeter` the display stage (decoded→on-glass, the ring wait +
|
/// (received→decoded); `displayMeter` the display stage (decoded→on-glass, the ring wait +
|
||||||
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
/// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates
|
||||||
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller
|
||||||
/// falls back to the stage-1 presenter.
|
/// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
|
||||||
|
/// (glass-gated) present cadence — see PresentPacing.
|
||||||
public init?(
|
public init?(
|
||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
pacing: PresentPacing = .arrival
|
||||||
) {
|
) {
|
||||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
|
self.pacing = pacing
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
@@ -327,16 +430,29 @@ public final class Stage2Pipeline {
|
|||||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||||
let vsyncClock = vsyncClock
|
let vsyncClock = vsyncClock
|
||||||
|
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
|
||||||
|
// the ring) so neither the render thread nor the presented handlers capture `self`.
|
||||||
|
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
while !token.isStopped {
|
while !token.isStopped {
|
||||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||||
debugStats?.flushIfDue(ring: ring)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||||
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
|
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
|
||||||
|
// frame is never bounced through putBack.
|
||||||
|
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||||
|
debugStats?.gatedWake()
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard !token.isStopped, let frame = ring.take() else {
|
guard !token.isStopped, let frame = ring.take() else {
|
||||||
|
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||||
debugStats?.emptyWake()
|
debugStats?.emptyWake()
|
||||||
debugStats?.flushIfDue(ring: ring)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||||
@@ -347,6 +463,12 @@ public final class Stage2Pipeline {
|
|||||||
let rendered = presenter.render(
|
let rendered = presenter.render(
|
||||||
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
|
||||||
) { presentedNs in
|
) { presentedNs in
|
||||||
|
// Stage-3: the flip reached glass (or was dropped) — free the present slot,
|
||||||
|
// then re-signal so the freshest waiting ring frame goes out immediately.
|
||||||
|
if let gate {
|
||||||
|
gate.release()
|
||||||
|
renderSignal.signal()
|
||||||
|
}
|
||||||
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
|
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on
|
||||||
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
|
// the Metal callback, converted to the CLOCK_REALTIME the meters live in.
|
||||||
let atNs = presentedNs
|
let atNs = presentedNs
|
||||||
@@ -361,8 +483,11 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
debugStats?.renderReturned(
|
debugStats?.renderReturned(
|
||||||
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
|
||||||
if !rendered { ring.putBack(frame) }
|
if !rendered {
|
||||||
debugStats?.flushIfDue(ring: ring)
|
gate?.release() // no present registered — its handler will never fire
|
||||||
|
ring.putBack(frame)
|
||||||
|
}
|
||||||
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderThread.name = "punktfunk-stage2-render"
|
renderThread.name = "punktfunk-stage2-render"
|
||||||
|
|||||||
@@ -594,13 +594,12 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.window?.isKeyWindow == true else { return }
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
self.onDisconnectRequest?()
|
self.onDisconnectRequest?()
|
||||||
}
|
}
|
||||||
capture.onToggleStats = { [weak self] in
|
capture.onCycleStats = { [weak self] in
|
||||||
guard self?.window?.isKeyWindow == true else { return }
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
// Flip the shared setting directly — every @AppStorage reader (the HUD's visibility,
|
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||||
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
|
// visibility/content, the Settings pickers) observes UserDefaults, so this is the
|
||||||
let defaults = UserDefaults.standard
|
// same as the menu path.
|
||||||
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||||
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
|
|
||||||
}
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
#if canImport(Metal)
|
||||||
|
import QuartzCore
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
|
||||||
|
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
|
||||||
|
final class PresentPacingTests: XCTestCase {
|
||||||
|
// MARK: - PresentGate
|
||||||
|
|
||||||
|
/// The core invariant: one present in flight. A second acquire while pending must fail (the
|
||||||
|
/// frame stays in the ring for the presented handler's re-signal); release reopens.
|
||||||
|
func testGateAdmitsOneInFlightPresent() {
|
||||||
|
let gate = PresentGate()
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||||
|
XCTAssertFalse(gate.tryAcquire(now: 0.001), "a pending present must close the gate")
|
||||||
|
gate.release()
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 0.002), "release must reopen the gate")
|
||||||
|
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
|
||||||
|
/// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate
|
||||||
|
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
|
||||||
|
func testGateForceOpensAfterStaleTimeout() {
|
||||||
|
let gate = PresentGate()
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||||
|
// Within the stale window the gate stays closed.
|
||||||
|
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||||
|
// Past it, the pending present is presumed lost: reopen, and count the force-clear.
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||||
|
XCTAssertEqual(gate.drainForced(), 1)
|
||||||
|
XCTAssertEqual(gate.drainForced(), 0, "drain resets the counter")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release is idempotent (a late stale-path double-release must be harmless), and an
|
||||||
|
/// acquire-then-release with no present (empty ring after arming) leaves the gate clean.
|
||||||
|
func testGateReleaseIsIdempotent() {
|
||||||
|
let gate = PresentGate()
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||||
|
gate.release()
|
||||||
|
gate.release() // the stale-cleared present's handler firing late
|
||||||
|
XCTAssertTrue(gate.tryAcquire(now: 0.001))
|
||||||
|
XCTAssertEqual(gate.drainForced(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PresenterChoice
|
||||||
|
|
||||||
|
func testPresenterChoiceDefaultsToStage2() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPresenterChoiceResolvesStage3FromSettingAndEnv() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage3", env: nil, allowStage1: true), .stage3)
|
||||||
|
// The env override wins over the persisted setting (A/B without touching settings)…
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage2", env: "stage3", allowStage1: true), .stage3)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage3", env: "stage2", allowStage1: true), .stage2)
|
||||||
|
// …but an EMPTY env var is "unset", not an override.
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
|
||||||
|
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
|
||||||
|
func testPresenterChoiceGatesStage1() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title">Toggle statistics overlay</property>
|
<property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
|
||||||
<property name="accelerator"><Control><Alt><Shift>s</property>
|
<property name="accelerator"><Control><Alt><Shift>s</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -324,10 +325,13 @@ pub fn show(
|
|||||||
"Software",
|
"Software",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let stats_row = adw::SwitchRow::builder()
|
let stats_row = ChoiceRow::new(
|
||||||
.title("Show statistics overlay")
|
&dialog,
|
||||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
inline,
|
||||||
.build();
|
"Statistics overlay",
|
||||||
|
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||||
|
&["Off", "Compact", "Normal", "Detailed"],
|
||||||
|
);
|
||||||
let fullscreen_row = adw::SwitchRow::builder()
|
let fullscreen_row = adw::SwitchRow::builder()
|
||||||
.title("Start streams in fullscreen")
|
.title("Start streams in fullscreen")
|
||||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||||
@@ -338,7 +342,7 @@ pub fn show(
|
|||||||
stream.add(compositor_row.widget());
|
stream.add(compositor_row.widget());
|
||||||
stream.add(decoder_row.widget());
|
stream.add(decoder_row.widget());
|
||||||
stream.add(&fullscreen_row);
|
stream.add(&fullscreen_row);
|
||||||
stream.add(&stats_row);
|
stream.add(stats_row.widget());
|
||||||
|
|
||||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||||
@@ -483,7 +487,11 @@ pub fn show(
|
|||||||
compositor_row.set_selected(comp_i as u32);
|
compositor_row.set_selected(comp_i as u32);
|
||||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||||
decoder_row.set_selected(dec_i as u32);
|
decoder_row.set_selected(dec_i as u32);
|
||||||
stats_row.set_active(s.show_stats);
|
let stats_i = StatsVerbosity::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == s.stats_verbosity())
|
||||||
|
.unwrap_or(0);
|
||||||
|
stats_row.set_selected(stats_i as u32);
|
||||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
@@ -509,7 +517,9 @@ pub fn show(
|
|||||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.to_string();
|
||||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||||
s.show_stats = stats_row.is_active();
|
s.set_stats_verbosity(
|
||||||
|
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||||
|
);
|
||||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
|||||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||||
|
|
||||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||||
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one
|
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||||
|
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||||
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
|
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
|
||||||
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
|
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
|
||||||
init failed.
|
init failed.
|
||||||
@@ -31,7 +32,9 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller
|
|||||||
chord (L1+R1+Start+Select, hold to disconnect) works the same.
|
chord (L1+R1+Start+Select, hold to disconnect) works the same.
|
||||||
|
|
||||||
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
|
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
|
||||||
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror).
|
hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one
|
||||||
|
line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder
|
||||||
|
path + per-stage latency equation); any tier but Off also emits the stdout mirror.
|
||||||
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
||||||
only, no Skia anywhere in the dependency tree.
|
only, no Skia anywhere in the dependency tree.
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
),
|
),
|
||||||
fullscreen: fullscreen_mode(),
|
fullscreen: fullscreen_mode(),
|
||||||
window_pos: window_pos(),
|
window_pos: window_pos(),
|
||||||
print_stats: settings_at_start.show_stats || arg_flag("--stats"),
|
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||||
|
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||||
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||||
|
v => v,
|
||||||
|
},
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
|
|||||||
@@ -322,7 +322,12 @@ mod session_main {
|
|||||||
window_title: format!("Punktfunk · {title}"),
|
window_title: format!("Punktfunk · {title}"),
|
||||||
fullscreen,
|
fullscreen,
|
||||||
window_pos: window_pos(),
|
window_pos: window_pos(),
|
||||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||||
|
// demoting an explicitly chosen richer tier.
|
||||||
|
stats_verbosity: match settings.stats_verbosity() {
|
||||||
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||||
|
v => v,
|
||||||
|
},
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
// This host's card carries the accent bar in the desktop client now.
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
|||||||
"Release captured input (click the stream to recapture)",
|
"Release captured input (click the stream to recapture)",
|
||||||
),
|
),
|
||||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
(
|
||||||
|
"Ctrl+Alt+Shift+S",
|
||||||
|
"Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"LB+RB+Start+Back",
|
"LB+RB+Start+Back",
|
||||||
"Controller: release input / leave fullscreen \u{2014} hold to disconnect",
|
"Controller: release input / leave fullscreen \u{2014} hold to disconnect",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen};
|
use super::{AppCtx, Screen};
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use punktfunk_core::config::GamepadPref;
|
use punktfunk_core::config::GamepadPref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
@@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[
|
|||||||
("xboxone", "Xbox One"),
|
("xboxone", "Xbox One"),
|
||||||
("dualshock4", "DualShock 4"),
|
("dualshock4", "DualShock 4"),
|
||||||
];
|
];
|
||||||
|
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||||
|
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||||
|
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||||
|
(StatsVerbosity::Off, "Off"),
|
||||||
|
(StatsVerbosity::Compact, "Compact"),
|
||||||
|
(StatsVerbosity::Normal, "Normal"),
|
||||||
|
(StatsVerbosity::Detailed, "Detailed"),
|
||||||
|
];
|
||||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||||
const COMPOSITORS: &[(&str, &str)] = &[
|
const COMPOSITORS: &[(&str, &str)] = &[
|
||||||
@@ -328,15 +337,14 @@ pub(crate) fn settings_page(
|
|||||||
)
|
)
|
||||||
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||||
|
|
||||||
let hud_toggle = setting_toggle(
|
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||||
ctx,
|
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||||
"Show the stats overlay (HUD)",
|
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||||
s.show_stats,
|
})
|
||||||
|s, on| s.show_stats = on,
|
|
||||||
)
|
|
||||||
.tooltip(
|
.tooltip(
|
||||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
|
||||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
|
||||||
|
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
|
||||||
);
|
);
|
||||||
|
|
||||||
let licenses_button = {
|
let licenses_button = {
|
||||||
@@ -368,7 +376,7 @@ pub(crate) fn settings_page(
|
|||||||
codec_combo.into(),
|
codec_combo.into(),
|
||||||
bitrate_box.into(),
|
bitrate_box.into(),
|
||||||
hdr_toggle.into(),
|
hdr_toggle.into(),
|
||||||
hud_toggle.into(),
|
hud_combo.into(),
|
||||||
]);
|
]);
|
||||||
controls
|
controls
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -254,6 +254,50 @@ pub fn probe_reachable_many(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
|
||||||
|
/// across every client (design/stats-unification.md): each tier is a strict superset of
|
||||||
|
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StatsVerbosity {
|
||||||
|
Off,
|
||||||
|
/// One glanceable line: fps · end-to-end ms · Mb/s.
|
||||||
|
Compact,
|
||||||
|
/// Stream mode plus the end-to-end latency percentiles and loss counters.
|
||||||
|
Normal,
|
||||||
|
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
|
||||||
|
Detailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsVerbosity {
|
||||||
|
/// Cycle order (also the settings pickers' option order).
|
||||||
|
pub const ALL: [StatsVerbosity; 4] = [
|
||||||
|
StatsVerbosity::Off,
|
||||||
|
StatsVerbosity::Compact,
|
||||||
|
StatsVerbosity::Normal,
|
||||||
|
StatsVerbosity::Detailed,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The next tier in the live cycle, wrapping back to Off.
|
||||||
|
pub fn next(self) -> StatsVerbosity {
|
||||||
|
match self {
|
||||||
|
StatsVerbosity::Off => StatsVerbosity::Compact,
|
||||||
|
StatsVerbosity::Compact => StatsVerbosity::Normal,
|
||||||
|
StatsVerbosity::Normal => StatsVerbosity::Detailed,
|
||||||
|
StatsVerbosity::Detailed => StatsVerbosity::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StatsVerbosity::Off => "Off",
|
||||||
|
StatsVerbosity::Compact => "Compact",
|
||||||
|
StatsVerbosity::Normal => "Normal",
|
||||||
|
StatsVerbosity::Detailed => "Detailed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -301,10 +345,16 @@ pub struct Settings {
|
|||||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||||
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`.
|
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||||
|
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||||
|
/// this as `show_hud`.
|
||||||
#[serde(alias = "show_hud")]
|
#[serde(alias = "show_hud")]
|
||||||
pub show_stats: bool,
|
pub show_stats: bool,
|
||||||
|
/// Stats overlay tier. `None` = a pre-tier store; resolve through
|
||||||
|
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stats_verbosity: Option<StatsVerbosity>,
|
||||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||||
pub fullscreen_on_stream: bool,
|
pub fullscreen_on_stream: bool,
|
||||||
@@ -322,6 +372,23 @@ fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
|
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||||
|
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||||
|
pub fn stats_verbosity(&self) -> StatsVerbosity {
|
||||||
|
self.stats_verbosity.unwrap_or(if self.show_stats {
|
||||||
|
StatsVerbosity::Normal
|
||||||
|
} else {
|
||||||
|
StatsVerbosity::Off
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
|
||||||
|
/// binaries that read the same settings file.
|
||||||
|
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
|
||||||
|
self.stats_verbosity = Some(v);
|
||||||
|
self.show_stats = v != StatsVerbosity::Off;
|
||||||
|
}
|
||||||
|
|
||||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||||
pub fn preferred_codec(&self) -> u8 {
|
pub fn preferred_codec(&self) -> u8 {
|
||||||
match self.codec.as_str() {
|
match self.codec.as_str() {
|
||||||
@@ -351,6 +418,7 @@ impl Default for Settings {
|
|||||||
adapter: String::new(),
|
adapter: String::new(),
|
||||||
hdr_enabled: true,
|
hdr_enabled: true,
|
||||||
show_stats: true,
|
show_stats: true,
|
||||||
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
}
|
}
|
||||||
@@ -432,6 +500,28 @@ mod tests {
|
|||||||
assert!(!s.library_enabled);
|
assert!(!s.library_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||||
|
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||||
|
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||||
|
#[test]
|
||||||
|
fn stats_verbosity_migrates_and_round_trips() {
|
||||||
|
let mut s: Settings = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
|
||||||
|
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
|
||||||
|
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
|
||||||
|
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Compact);
|
||||||
|
assert!(s.show_stats);
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Off);
|
||||||
|
assert!(!s.show_stats);
|
||||||
|
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||||
|
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||||
|
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
|
||||||
|
// The tier serializes lowercase — the file stays human-readable.
|
||||||
|
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
|
||||||
|
}
|
||||||
|
|
||||||
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
||||||
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -241,7 +242,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
on_off(s.show_stats).into(),
|
s.stats_verbosity().label().into(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
RowSpec {
|
RowSpec {
|
||||||
@@ -274,7 +275,10 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||||
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.",
|
RowId::Stats => {
|
||||||
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +336,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||||
}
|
}
|
||||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||||
RowId::Stats => toggle(&mut s.show_stats, delta, wrap),
|
RowId::Stats => {
|
||||||
|
let cur = StatsVerbosity::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == s.stats_verbosity());
|
||||||
|
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||||
|
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.is_some()
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-14
@@ -8,8 +8,10 @@
|
|||||||
//! library; the app quits only on B/window-close).
|
//! library; the app quits only on B/window-close).
|
||||||
//!
|
//!
|
||||||
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
|
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
|
||||||
//! line after the first presented frame, `stats: …` lines once per window while enabled
|
//! line after the first presented frame, `stats: …` lines once per window while the
|
||||||
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
|
//! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed;
|
||||||
|
//! the stdout line always carries the full Detailed text so parsers see a stable
|
||||||
|
//! shape). Logs go to stderr (the binary configures tracing so).
|
||||||
|
|
||||||
use crate::input::Capture;
|
use crate::input::Capture;
|
||||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||||
@@ -17,6 +19,7 @@ use crate::vk::{FrameInput, Presenter};
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use pf_client_core::gamepad::GamepadService;
|
use pf_client_core::gamepad::GamepadService;
|
||||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use pf_client_core::video::VulkanDecodeDevice;
|
use pf_client_core::video::VulkanDecodeDevice;
|
||||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -37,8 +40,9 @@ pub struct SessionOpts {
|
|||||||
/// changing content, not a window jumping displays). Fullscreen follows the display
|
/// changing content, not a window jumping displays). Fullscreen follows the display
|
||||||
/// this lands on.
|
/// this lands on.
|
||||||
pub window_pos: Option<(i32, i32)>,
|
pub window_pos: Option<(i32, i32)>,
|
||||||
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
|
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
|
||||||
pub print_stats: bool,
|
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
|
||||||
|
pub stats_verbosity: StatsVerbosity,
|
||||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||||
pub json_status: bool,
|
pub json_status: bool,
|
||||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||||
@@ -162,8 +166,11 @@ struct StreamState {
|
|||||||
// all) demotes the decoder to software via the shared flag — once per session.
|
// all) demotes the decoder to software via the shared flag — once per session.
|
||||||
dmabuf_demoted: bool,
|
dmabuf_demoted: bool,
|
||||||
hw_fails: u32,
|
hw_fails: u32,
|
||||||
/// The OSD's text (multi-line; rebuilt each Stats window).
|
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||||
osd_text: String,
|
osd_text: String,
|
||||||
|
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
||||||
|
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||||
|
last_stats: Option<Stats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -207,6 +214,7 @@ impl StreamState {
|
|||||||
dmabuf_demoted: false,
|
dmabuf_demoted: false,
|
||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
|
last_stats: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +359,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
let mouse = sdl.mouse();
|
let mouse = sdl.mouse();
|
||||||
|
|
||||||
let mut fullscreen = opts.fullscreen;
|
let mut fullscreen = opts.fullscreen;
|
||||||
let mut print_stats = opts.print_stats;
|
let mut stats_verbosity = opts.stats_verbosity;
|
||||||
let mut overlay_frame: Option<OverlayFrame> = None;
|
let mut overlay_frame: Option<OverlayFrame> = None;
|
||||||
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
||||||
// events on desktop, and the door Steam's on-screen keyboard types through under
|
// events on desktop, and the door Steam's on-screen keyboard types through under
|
||||||
@@ -452,7 +460,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if chord && sc == Scancode::S {
|
if chord && sc == Scancode::S {
|
||||||
print_stats = !print_stats;
|
stats_verbosity = stats_verbosity.next();
|
||||||
|
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
|
||||||
|
// Re-render the OSD from the last window immediately — waiting
|
||||||
|
// for the next Stats event would lag the keypress by up to 1 s.
|
||||||
|
if let Some(st) = &mut stream {
|
||||||
|
let text = match &st.last_stats {
|
||||||
|
Some(s) => stats_text(
|
||||||
|
stats_verbosity,
|
||||||
|
&st.mode_line,
|
||||||
|
s,
|
||||||
|
&st.presented,
|
||||||
|
st.hdr,
|
||||||
|
presenter.hdr_active(),
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
st.osd_text = text;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||||
@@ -647,15 +672,27 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
SessionEvent::Stats(s) => {
|
SessionEvent::Stats(s) => {
|
||||||
st.osd_text = stats_text(
|
st.osd_text = stats_text(
|
||||||
|
stats_verbosity,
|
||||||
&st.mode_line,
|
&st.mode_line,
|
||||||
&s,
|
&s,
|
||||||
&st.presented,
|
&st.presented,
|
||||||
st.hdr,
|
st.hdr,
|
||||||
presenter.hdr_active(),
|
presenter.hdr_active(),
|
||||||
);
|
);
|
||||||
if print_stats {
|
if stats_verbosity != StatsVerbosity::Off {
|
||||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
// The stdout line is the machine interface (shell status card,
|
||||||
|
// scripts) — always the full Detailed text, whatever the OSD tier.
|
||||||
|
let full = stats_text(
|
||||||
|
StatsVerbosity::Detailed,
|
||||||
|
&st.mode_line,
|
||||||
|
&s,
|
||||||
|
&st.presented,
|
||||||
|
st.hdr,
|
||||||
|
presenter.hdr_active(),
|
||||||
|
);
|
||||||
|
println!("stats: {}", full.replace('\n', " | "));
|
||||||
}
|
}
|
||||||
|
st.last_stats = Some(s);
|
||||||
}
|
}
|
||||||
SessionEvent::Failed {
|
SessionEvent::Failed {
|
||||||
msg,
|
msg,
|
||||||
@@ -728,7 +765,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
(print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()),
|
(stats_verbosity != StatsVerbosity::Off && !st.osd_text.is_empty())
|
||||||
|
.then_some(st.osd_text.as_str()),
|
||||||
hint,
|
hint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -996,20 +1034,43 @@ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
|||||||
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||||
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
||||||
|
|
||||||
/// 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 at the given tier
|
||||||
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
|
/// (the Android client's vocabulary, each a strict superset of the previous):
|
||||||
|
/// Compact = one glanceable line, Normal = mode + end-to-end percentiles + loss,
|
||||||
|
/// Detailed = decoder path, HDR tag and the per-stage equation on top. Off reads empty.
|
||||||
|
/// Multi-line for the console-UI panel; the stdout `stats:` line joins Detailed with `|`.
|
||||||
///
|
///
|
||||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
/// 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
|
/// 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.
|
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||||
fn stats_text(
|
fn stats_text(
|
||||||
|
verbosity: StatsVerbosity,
|
||||||
mode_line: &str,
|
mode_line: &str,
|
||||||
s: &Stats,
|
s: &Stats,
|
||||||
p: &PresentedWindow,
|
p: &PresentedWindow,
|
||||||
hdr_stream: bool,
|
hdr_stream: bool,
|
||||||
hdr_display: bool,
|
hdr_display: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut text = format!(
|
match verbosity {
|
||||||
|
StatsVerbosity::Off => return String::new(),
|
||||||
|
StatsVerbosity::Compact => {
|
||||||
|
// fps · e2e ms · Mb/s — the latency term waits for the first presenter
|
||||||
|
// window (0 = no capture→displayed samples yet).
|
||||||
|
let mut text = format!("{:.0} fps", s.fps);
|
||||||
|
if p.e2e_p50_ms > 0.0 {
|
||||||
|
text.push_str(&format!(" · {:.1} ms", p.e2e_p50_ms));
|
||||||
|
}
|
||||||
|
text.push_str(&format!(" · {:.0} Mb/s", s.mbps));
|
||||||
|
if s.lost > 0 {
|
||||||
|
text.push_str(&format!(" · lost {}", s.lost));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
StatsVerbosity::Normal | StatsVerbosity::Detailed => {}
|
||||||
|
}
|
||||||
|
let detailed = verbosity == StatsVerbosity::Detailed;
|
||||||
|
let mut text = if detailed {
|
||||||
|
format!(
|
||||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||||
s.fps,
|
s.fps,
|
||||||
s.mbps,
|
s.mbps,
|
||||||
@@ -1019,11 +1080,15 @@ fn stats_text(
|
|||||||
(true, false) => " · HDR→SDR",
|
(true, false) => " · HDR→SDR",
|
||||||
_ => "",
|
_ => "",
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
} else {
|
||||||
|
format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps)
|
||||||
|
};
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
||||||
p.e2e_p50_ms, p.e2e_p95_ms
|
p.e2e_p50_ms, p.e2e_p95_ms
|
||||||
));
|
));
|
||||||
|
if detailed {
|
||||||
if s.split {
|
if s.split {
|
||||||
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
|
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
|
||||||
} else {
|
} else {
|
||||||
@@ -1033,8 +1098,74 @@ fn stats_text(
|
|||||||
" · decode {:.1} · display {:.1} ms",
|
" · decode {:.1} · display {:.1} ms",
|
||||||
s.decode_ms, p.display_ms
|
s.decode_ms, p.display_ms
|
||||||
));
|
));
|
||||||
|
}
|
||||||
if s.lost > 0 {
|
if s.lost > 0 {
|
||||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> (Stats, PresentedWindow) {
|
||||||
|
(
|
||||||
|
Stats {
|
||||||
|
fps: 119.6,
|
||||||
|
mbps: 24.3,
|
||||||
|
host_net_ms: 2.1,
|
||||||
|
host_ms: 1.2,
|
||||||
|
net_ms: 0.9,
|
||||||
|
split: true,
|
||||||
|
decode_ms: 1.8,
|
||||||
|
lost: 3,
|
||||||
|
lost_pct: 0.4,
|
||||||
|
decoder: "vulkan",
|
||||||
|
},
|
||||||
|
PresentedWindow {
|
||||||
|
e2e_p50_ms: 6.4,
|
||||||
|
e2e_p95_ms: 9.1,
|
||||||
|
display_ms: 1.1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tier ladder: Off is empty, Compact is one line, Normal adds the mode + e2e
|
||||||
|
/// lines but no stage terms or decoder tag, Detailed carries everything.
|
||||||
|
#[test]
|
||||||
|
fn stats_text_tiers() {
|
||||||
|
let (s, p) = sample();
|
||||||
|
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false);
|
||||||
|
|
||||||
|
assert_eq!(text(StatsVerbosity::Off), "");
|
||||||
|
|
||||||
|
let compact = text(StatsVerbosity::Compact);
|
||||||
|
assert_eq!(compact, "120 fps · 6.4 ms · 24 Mb/s · lost 3");
|
||||||
|
assert_eq!(compact.lines().count(), 1);
|
||||||
|
|
||||||
|
let normal = text(StatsVerbosity::Normal);
|
||||||
|
assert!(normal.starts_with("1920×1080@120 · 120 fps · 24.3 Mb/s\n"));
|
||||||
|
assert!(normal.contains("e2e 6.4/9.1 ms (p50/p95)"));
|
||||||
|
assert!(normal.contains("lost 3 (0.4%)"));
|
||||||
|
assert!(!normal.contains("vulkan"), "decoder tag is Detailed-only");
|
||||||
|
assert!(!normal.contains("decode"), "stage terms are Detailed-only");
|
||||||
|
|
||||||
|
let detailed = text(StatsVerbosity::Detailed);
|
||||||
|
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||||
|
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||||
|
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact omits the latency term until the presenter's first e2e window lands.
|
||||||
|
#[test]
|
||||||
|
fn compact_waits_for_e2e() {
|
||||||
|
let (mut s, _) = sample();
|
||||||
|
s.lost = 0;
|
||||||
|
let p = PresentedWindow::default();
|
||||||
|
assert_eq!(
|
||||||
|
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false),
|
||||||
|
"120 fps · 24 Mb/s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1242,6 +1242,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
welcome.chroma_format,
|
welcome.chroma_format,
|
||||||
welcome.audio_channels,
|
welcome.audio_channels,
|
||||||
welcome.codec,
|
welcome.codec,
|
||||||
|
welcome.host_caps,
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1261,6 +1262,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
chroma_format,
|
chroma_format,
|
||||||
audio_channels,
|
audio_channels,
|
||||||
codec,
|
codec,
|
||||||
|
host_caps,
|
||||||
) = match setup.await {
|
) = match setup.await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1282,12 +1284,56 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
codec,
|
codec,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Input task: embedder events → QUIC datagrams.
|
// Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
||||||
|
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
||||||
|
// folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
|
||||||
|
// datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
|
||||||
|
// per-transition event would corrupt held pad state until the *next* change — a held trigger
|
||||||
|
// stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
|
||||||
|
// reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
|
||||||
|
// interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
|
||||||
|
// Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||||
|
// getting the legacy per-transition gamepad events.
|
||||||
let input_conn = conn.clone();
|
let input_conn = conn.clone();
|
||||||
|
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = input_rx.recv().await {
|
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||||
|
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||||
|
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||||
|
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||||
|
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||||
|
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
ev = input_rx.recv() => {
|
||||||
|
let Some(ev) = ev else { break };
|
||||||
|
let idx = ev.flags as usize;
|
||||||
|
if gamepad_snapshots
|
||||||
|
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
|
||||||
|
&& idx < MAX_PADS
|
||||||
|
{
|
||||||
|
let snap = pads[idx].get_or_insert(GamepadSnapshot {
|
||||||
|
pad: idx as u8,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// Unknown axis ids don't send (the host's legacy fold drops them too).
|
||||||
|
if snap.fold(&ev) {
|
||||||
|
snap.seq = snap.seq.wrapping_add(1);
|
||||||
|
let _ = input_conn
|
||||||
|
.send_datagram(snap.to_event().encode().to_vec().into());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
||||||
}
|
}
|
||||||
|
_ = refresh.tick() => {
|
||||||
|
for snap in pads.iter_mut().flatten() {
|
||||||
|
snap.seq = snap.seq.wrapping_add(1);
|
||||||
|
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub enum InputKind {
|
|||||||
TouchMove = 10,
|
TouchMove = 10,
|
||||||
/// Touch ends. Only `code` (the touch id) is used.
|
/// Touch ends. Only `code` (the touch id) is used.
|
||||||
TouchUp = 11,
|
TouchUp = 11,
|
||||||
|
/// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
|
||||||
|
///
|
||||||
|
/// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
|
||||||
|
/// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
|
||||||
|
/// the host's accumulated pad state until the *next* change (a held trigger stays wrong
|
||||||
|
/// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
|
||||||
|
/// sequence number lets the host drop stale reorders — the same idempotent-state discipline
|
||||||
|
/// as the host→client rumble refresh. Sent only when the host advertised
|
||||||
|
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||||
|
/// receiving the per-transition events.
|
||||||
|
GamepadState = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||||
@@ -111,11 +122,122 @@ impl InputKind {
|
|||||||
9 => TouchDown,
|
9 => TouchDown,
|
||||||
10 => TouchMove,
|
10 => TouchMove,
|
||||||
11 => TouchUp,
|
11 => TouchUp,
|
||||||
|
12 => GamepadState,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||||
|
/// client's snapshot fold and the host's per-pad accumulators.
|
||||||
|
pub const MAX_PADS: usize = 16;
|
||||||
|
|
||||||
|
/// One pad's complete state, packed into a single [`InputKind::GamepadState`] event — the
|
||||||
|
/// whole 18-byte wire layout is reused, nothing is appended:
|
||||||
|
///
|
||||||
|
/// - `code` = `buttons` (the [`gamepad`] `BTN_*` bitmask, extended bits included)
|
||||||
|
/// - `x` = `ls_x << 16 | ls_y` (two i16 halves, wire stick convention: **+y = up**)
|
||||||
|
/// - `y` = `rs_x << 16 | rs_y`
|
||||||
|
/// - `flags` = `seq << 24 | left_trigger << 16 | right_trigger << 8 | pad`
|
||||||
|
///
|
||||||
|
/// `seq` is a per-pad wrapping u8, bumped on every send (changes *and* refreshes); the host
|
||||||
|
/// applies a snapshot only when `seq` is newer than the last applied one (wrapping i8
|
||||||
|
/// compare), so a datagram the network reordered can't roll held state backwards. The wrap
|
||||||
|
/// window (128 sends) dwarfs any real reorder window, and the client's periodic refresh
|
||||||
|
/// heals the pathological case anyway.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct GamepadSnapshot {
|
||||||
|
/// Pad index 0..[`MAX_PADS`].
|
||||||
|
pub pad: u8,
|
||||||
|
/// Wrapping send counter (see the type docs for the reorder gate).
|
||||||
|
pub seq: u8,
|
||||||
|
/// [`gamepad`] `BTN_*` bitmask.
|
||||||
|
pub buttons: u32,
|
||||||
|
/// Triggers 0..255 (the [`gamepad::AXIS_LT`]/[`gamepad::AXIS_RT`] convention).
|
||||||
|
pub left_trigger: u8,
|
||||||
|
pub right_trigger: u8,
|
||||||
|
/// Sticks −32768..32767, **+y = up** (the wire convention).
|
||||||
|
pub ls_x: i16,
|
||||||
|
pub ls_y: i16,
|
||||||
|
pub rs_x: i16,
|
||||||
|
pub rs_y: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamepadSnapshot {
|
||||||
|
/// Pack into the fixed [`InputEvent`] layout (kind = [`InputKind::GamepadState`]).
|
||||||
|
pub fn to_event(&self) -> InputEvent {
|
||||||
|
InputEvent {
|
||||||
|
kind: InputKind::GamepadState,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: self.buttons,
|
||||||
|
x: ((self.ls_x as u16 as i32) << 16) | (self.ls_y as u16 as i32),
|
||||||
|
y: ((self.rs_x as u16 as i32) << 16) | (self.rs_y as u16 as i32),
|
||||||
|
flags: ((self.seq as u32) << 24)
|
||||||
|
| ((self.left_trigger as u32) << 16)
|
||||||
|
| ((self.right_trigger as u32) << 8)
|
||||||
|
| (self.pad as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack from a [`InputKind::GamepadState`] event; `None` for any other kind.
|
||||||
|
pub fn from_event(ev: &InputEvent) -> Option<GamepadSnapshot> {
|
||||||
|
if ev.kind != InputKind::GamepadState {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GamepadSnapshot {
|
||||||
|
pad: ev.flags as u8,
|
||||||
|
seq: (ev.flags >> 24) as u8,
|
||||||
|
buttons: ev.code,
|
||||||
|
left_trigger: (ev.flags >> 16) as u8,
|
||||||
|
right_trigger: (ev.flags >> 8) as u8,
|
||||||
|
ls_x: (ev.x >> 16) as i16,
|
||||||
|
ls_y: ev.x as i16,
|
||||||
|
rs_x: (ev.y >> 16) as i16,
|
||||||
|
rs_y: ev.y as i16,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one per-transition [`GamepadButton`](InputKind::GamepadButton) /
|
||||||
|
/// [`GamepadAxis`](InputKind::GamepadAxis) event into this snapshot (`seq`/`pad` untouched).
|
||||||
|
/// `false` = not a foldable event / unknown axis id (snapshot unchanged).
|
||||||
|
pub fn fold(&mut self, ev: &InputEvent) -> bool {
|
||||||
|
match ev.kind {
|
||||||
|
InputKind::GamepadButton => {
|
||||||
|
if ev.x != 0 {
|
||||||
|
self.buttons |= ev.code;
|
||||||
|
} else {
|
||||||
|
self.buttons &= !ev.code;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
InputKind::GamepadAxis => {
|
||||||
|
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||||
|
let trigger = ev.x.clamp(0, 255) as u8;
|
||||||
|
match ev.code {
|
||||||
|
gamepad::AXIS_LS_X => self.ls_x = stick,
|
||||||
|
gamepad::AXIS_LS_Y => self.ls_y = stick,
|
||||||
|
gamepad::AXIS_RS_X => self.rs_x = stick,
|
||||||
|
gamepad::AXIS_RS_Y => self.rs_y = stick,
|
||||||
|
gamepad::AXIS_LT => self.left_trigger = trigger,
|
||||||
|
gamepad::AXIS_RT => self.right_trigger = trigger,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `seq` supersedes `last` (wrapping u8 distance, forward window of 127) — the
|
||||||
|
/// host's reorder gate. `None` (nothing applied yet) always accepts.
|
||||||
|
pub fn seq_newer(seq: u8, last: Option<u8>) -> bool {
|
||||||
|
match last {
|
||||||
|
None => true,
|
||||||
|
Some(l) => (seq.wrapping_sub(l) as i8) > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as
|
/// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as
|
||||||
/// `PunktfunkInputEvent`.
|
/// `PunktfunkInputEvent`.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -199,7 +321,79 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
||||||
}
|
}
|
||||||
// 12 (one past TouchUp) is not a valid kind.
|
// 13 (one past GamepadState) is not a valid kind.
|
||||||
assert_eq!(InputKind::from_u8(12), None);
|
assert_eq!(InputKind::from_u8(13), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_snapshot_roundtrip() {
|
||||||
|
let s = GamepadSnapshot {
|
||||||
|
pad: 3,
|
||||||
|
seq: 200,
|
||||||
|
buttons: gamepad::BTN_A | gamepad::BTN_PADDLE4 | gamepad::BTN_MISC1,
|
||||||
|
left_trigger: 255,
|
||||||
|
right_trigger: 1,
|
||||||
|
ls_x: -32768,
|
||||||
|
ls_y: 32767,
|
||||||
|
rs_x: -1,
|
||||||
|
rs_y: 12345,
|
||||||
|
};
|
||||||
|
let ev = s.to_event();
|
||||||
|
assert_eq!(ev.kind, InputKind::GamepadState);
|
||||||
|
// Survives the wire encode/decode unchanged.
|
||||||
|
let dec = InputEvent::decode(&ev.encode()).unwrap();
|
||||||
|
assert_eq!(GamepadSnapshot::from_event(&dec), Some(s));
|
||||||
|
// Non-snapshot kinds unpack to None.
|
||||||
|
let axis = InputEvent {
|
||||||
|
kind: InputKind::GamepadAxis,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: gamepad::AXIS_LT,
|
||||||
|
x: 255,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(GamepadSnapshot::from_event(&axis), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_snapshot_fold() {
|
||||||
|
let mut s = GamepadSnapshot::default();
|
||||||
|
let ev = |kind: InputKind, code: u32, x: i32| InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
};
|
||||||
|
// Button down/up sets and clears its bit.
|
||||||
|
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 1)));
|
||||||
|
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_RB, 1)));
|
||||||
|
assert_eq!(s.buttons, gamepad::BTN_A | gamepad::BTN_RB);
|
||||||
|
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 0)));
|
||||||
|
assert_eq!(s.buttons, gamepad::BTN_RB);
|
||||||
|
// Axes land in their slots; triggers clamp to 0..255, sticks to i16.
|
||||||
|
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LT, 300)));
|
||||||
|
assert_eq!(s.left_trigger, 255);
|
||||||
|
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LS_Y, -40000)));
|
||||||
|
assert_eq!(s.ls_y, i16::MIN);
|
||||||
|
// Unknown axis / unrelated kind leave the snapshot untouched.
|
||||||
|
assert!(!s.fold(&ev(InputKind::GamepadAxis, 99, 1)));
|
||||||
|
assert!(!s.fold(&ev(InputKind::KeyDown, 30, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_snapshot_seq_gate() {
|
||||||
|
// First snapshot always applies.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(0, None));
|
||||||
|
// Strictly newer within the forward window applies; equal/older doesn't.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(6, Some(5)));
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(5, Some(5)));
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(4, Some(5)));
|
||||||
|
// Wraps: 2 supersedes 250 (forward distance 8), not the reverse.
|
||||||
|
assert!(GamepadSnapshot::seq_newer(2, Some(250)));
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(250, Some(2)));
|
||||||
|
// Exactly half the window away is treated as stale (i8 > 0 excludes -128).
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(133, Some(5)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ pub const QUIT_CLOSE_CODE: u32 = 0x51;
|
|||||||
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
|
||||||
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
||||||
|
|
||||||
|
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||||
|
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||||
|
/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||||
|
/// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||||
|
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||||
|
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||||
|
|
||||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
/// advertise this.
|
/// advertise this.
|
||||||
@@ -317,6 +324,11 @@ pub struct Welcome {
|
|||||||
/// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it
|
/// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it
|
||||||
/// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC).
|
/// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC).
|
||||||
pub codec: u8,
|
pub codec: u8,
|
||||||
|
/// Host input capabilities — a bitfield of [`HOST_CAP_GAMEPAD_STATE`]. The client picks the
|
||||||
|
/// wire form its gamepad events take from this (snapshots for a capable host, the legacy
|
||||||
|
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
|
||||||
|
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
|
||||||
|
pub host_caps: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `client → host`: data plane is bound, begin streaming.
|
/// `client → host`: data plane is bound, begin streaming.
|
||||||
@@ -949,6 +961,8 @@ impl Welcome {
|
|||||||
b.push(self.audio_channels);
|
b.push(self.audio_channels);
|
||||||
// Resolved video codec at offset 66 — older clients stop before this → HEVC.
|
// Resolved video codec at offset 66 — older clients stop before this → HEVC.
|
||||||
b.push(self.codec);
|
b.push(self.codec);
|
||||||
|
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
|
||||||
|
b.push(self.host_caps);
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1045,9 @@ impl Welcome {
|
|||||||
Some(CODEC_AV1) => CODEC_AV1,
|
Some(CODEC_AV1) => CODEC_AV1,
|
||||||
_ => CODEC_HEVC,
|
_ => CODEC_HEVC,
|
||||||
},
|
},
|
||||||
|
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||||
|
// snapshots; the client keeps sending legacy per-transition events).
|
||||||
|
host_caps: b.get(67).copied().unwrap_or(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2228,6 +2245,7 @@ mod tests {
|
|||||||
chroma_format: CHROMA_IDC_444,
|
chroma_format: CHROMA_IDC_444,
|
||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
};
|
};
|
||||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
}
|
}
|
||||||
@@ -2329,6 +2347,7 @@ mod tests {
|
|||||||
chroma_format: CHROMA_IDC_420,
|
chroma_format: CHROMA_IDC_420,
|
||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_H264,
|
codec: CODEC_H264,
|
||||||
|
host_caps: 0,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -2526,9 +2545,10 @@ mod tests {
|
|||||||
chroma_format: CHROMA_IDC_444,
|
chroma_format: CHROMA_IDC_444,
|
||||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||||
codec: CODEC_HEVC,
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
};
|
};
|
||||||
let wenc = w.encode();
|
let wenc = w.encode();
|
||||||
assert_eq!(wenc.len(), 67); // 60 base + 4 colour + 1 chroma + 1 audio-channels + 1 codec byte
|
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||||
@@ -2571,6 +2591,12 @@ mod tests {
|
|||||||
CHROMA_IDC_444
|
CHROMA_IDC_444
|
||||||
); // full form carries 4:4:4
|
); // full form carries 4:4:4
|
||||||
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||||
|
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
|
||||||
|
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().host_caps,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -760,6 +760,56 @@ impl IddPushCapturer {
|
|||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
|
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||||
|
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||||
|
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||||
|
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||||
|
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||||
|
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||||
|
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||||
|
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||||
|
let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||||
|
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||||
|
HighPart: (target.adapter_luid >> 32) as i32,
|
||||||
|
});
|
||||||
|
match Self::open_on(target.clone(), preferred, client_10bit, luid) {
|
||||||
|
Ok(me) => Ok(me),
|
||||||
|
Err(e) => {
|
||||||
|
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||||
|
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||||
|
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||||
|
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||||
|
// exact same mismatch.
|
||||||
|
let driver_luid = e
|
||||||
|
.downcast_ref::<AttachTexFail>()
|
||||||
|
.map(|tf| tf.driver_luid)
|
||||||
|
.filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid));
|
||||||
|
let Some(packed) = driver_luid else {
|
||||||
|
return Err(e);
|
||||||
|
};
|
||||||
|
let drv = LUID {
|
||||||
|
LowPart: (packed & 0xffff_ffff) as u32,
|
||||||
|
HighPart: (packed >> 32) as i32,
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||||
|
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||||
|
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||||
|
driver's reported adapter"
|
||||||
|
);
|
||||||
|
Self::open_on(target, preferred, client_10bit, drv)
|
||||||
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_on(
|
||||||
|
target: WinCaptureTarget,
|
||||||
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
client_10bit: bool,
|
||||||
|
luid: LUID,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (pw, ph, _hz) = preferred
|
let (pw, ph, _hz) = preferred
|
||||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||||
@@ -829,10 +879,10 @@ impl IddPushCapturer {
|
|||||||
} else {
|
} else {
|
||||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||||
};
|
};
|
||||||
// Create our device on the discrete render GPU (where NVENC runs); the driver must render
|
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||||
// the swap-chain on the SAME adapter for the shared textures to open (it reports its actual
|
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||||
// render LUID into the header so we can detect a mismatch).
|
// shared textures to open (it reports its actual render LUID into the header, which
|
||||||
let luid = resolve_render_adapter_luid_or(target.adapter_luid);
|
// `open_inner` uses to rebind once if this mismatches).
|
||||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||||
let adapter: IDXGIAdapter1 = factory
|
let adapter: IDXGIAdapter1 = factory
|
||||||
.EnumAdapterByLuid(luid)
|
.EnumAdapterByLuid(luid)
|
||||||
@@ -991,13 +1041,31 @@ impl IddPushCapturer {
|
|||||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||||
// log_driver_status_once).
|
// log_driver_status_once).
|
||||||
let st = unsafe { (*self.header).driver_status };
|
let st = unsafe { (*self.header).driver_status };
|
||||||
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) {
|
if st == DRV_STATUS_TEX_FAIL {
|
||||||
|
// SAFETY: as above — in-bounds, aligned word reads of best-effort diagnostic fields
|
||||||
|
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||||
|
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||||
|
// (frame_transport.rs step 2), so it is valid here.
|
||||||
|
let (detail, lo, hi) = unsafe {
|
||||||
|
(
|
||||||
|
(*self.header).driver_status_detail,
|
||||||
|
(*self.header).driver_render_luid_low,
|
||||||
|
(*self.header).driver_render_luid_high,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||||
|
return Err(anyhow::Error::new(AttachTexFail {
|
||||||
|
detail,
|
||||||
|
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if st == DRV_STATUS_NO_DEVICE1 {
|
||||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||||
let detail = unsafe { (*self.header).driver_status_detail };
|
let detail = unsafe { (*self.header).driver_status_detail };
|
||||||
bail!(
|
bail!(
|
||||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||||
render-adapter mismatch?)"
|
the driver has no ID3D11Device1 to open shared resources)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||||
@@ -1415,17 +1483,31 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`.
|
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||||
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
|
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||||
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
|
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||||
return l;
|
#[derive(Debug)]
|
||||||
}
|
struct AttachTexFail {
|
||||||
LUID {
|
detail: u32,
|
||||||
LowPart: (fallback_packed & 0xffff_ffff) as u32,
|
driver_luid: i64,
|
||||||
HighPart: (fallback_packed >> 32) as i32,
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AttachTexFail {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||||
|
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||||
|
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||||
|
self.detail,
|
||||||
|
(self.driver_luid >> 32) as i32,
|
||||||
|
(self.driver_luid & 0xffff_ffff) as u32,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AttachTexFail {}
|
||||||
|
|
||||||
impl Capturer for IddPushCapturer {
|
impl Capturer for IddPushCapturer {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(20);
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ pub struct HostConfig {
|
|||||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||||
pub vdisplay: Option<String>,
|
pub vdisplay: Option<String>,
|
||||||
|
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||||
|
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||||
|
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||||
|
/// or reboot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or
|
||||||
|
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
|
||||||
|
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
|
||||||
|
pub recover_session_cmd: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostConfig {
|
impl HostConfig {
|
||||||
@@ -101,6 +108,8 @@ impl HostConfig {
|
|||||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||||
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
|
.filter(|s| !s.trim().is_empty()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ impl GpuInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
|
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
|
||||||
/// same-(vendor,device) twins, in enumeration order).
|
/// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so
|
||||||
|
/// twin numbering is stable for the boot, see [`enumerate`]).
|
||||||
fn assign_ids(gpus: &mut [GpuInfo]) {
|
fn assign_ids(gpus: &mut [GpuInfo]) {
|
||||||
for i in 0..gpus.len() {
|
for i in 0..gpus.len() {
|
||||||
let occ = gpus[..i]
|
let occ = gpus[..i]
|
||||||
@@ -268,6 +269,13 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Deterministic inventory order. DXGI enumerates the adapter owning the primary display first,
|
||||||
|
// and the vdisplay flow itself MOVES the primary mid-session (exclusive mode turns the physical
|
||||||
|
// screens off) — which flipped every order-relative selection between IDENTICAL twin GPUs from
|
||||||
|
// one call to the next (the max-VRAM tie, the env-substring first match, occurrence numbering):
|
||||||
|
// monitor ADD pinned the driver to one twin, the ring open picked the other → TEX_FAIL. LUIDs
|
||||||
|
// are creation-ordered and stable for the boot, so sorting by them makes selection stable too.
|
||||||
|
out.sort_by_key(|g| ((g.handle.luid_high as i64) << 32) | g.handle.luid_low as i64);
|
||||||
assign_ids(&mut out);
|
assign_ids(&mut out);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ impl InputInjector for KwinFakeInjector {
|
|||||||
self.fake.touch_frame();
|
self.fake.touch_frame();
|
||||||
}
|
}
|
||||||
// Gamepads are injected through uinput, not the compositor.
|
// Gamepads are injected through uinput, not the compositor.
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||||
}
|
}
|
||||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||||
self.queue
|
self.queue
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ fn kind_bit(kind: InputKind) -> u32 {
|
|||||||
InputKind::TouchUp => 9,
|
InputKind::TouchUp => 9,
|
||||||
InputKind::GamepadButton => 10,
|
InputKind::GamepadButton => 10,
|
||||||
InputKind::GamepadAxis => 11,
|
InputKind::GamepadAxis => 11,
|
||||||
|
InputKind::GamepadState => 12,
|
||||||
};
|
};
|
||||||
1 << i
|
1 << i
|
||||||
}
|
}
|
||||||
@@ -545,7 +546,7 @@ impl EiState {
|
|||||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||||
DeviceCapability::Touch
|
DeviceCapability::Touch
|
||||||
}
|
}
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||||
};
|
};
|
||||||
self.injected += 1;
|
self.injected += 1;
|
||||||
let n = self.injected;
|
let n = self.injected;
|
||||||
@@ -692,7 +693,9 @@ impl EiState {
|
|||||||
Some(t) => t.up(ev.code),
|
Some(t) => t.up(ev.code),
|
||||||
None => emitted = false,
|
None => emitted = false,
|
||||||
},
|
},
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||||
|
emitted = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if emitted {
|
if emitted {
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ impl InputInjector for WlrootsInjector {
|
|||||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,9 +297,10 @@ impl InputInjector for SendInputInjector {
|
|||||||
};
|
};
|
||||||
self.send(&[key(ki)])
|
self.send(&[key(ki)])
|
||||||
}
|
}
|
||||||
// Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op.
|
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||||
InputKind::GamepadButton
|
InputKind::GamepadButton
|
||||||
| InputKind::GamepadAxis
|
| InputKind::GamepadAxis
|
||||||
|
| InputKind::GamepadState
|
||||||
| InputKind::TouchDown
|
| InputKind::TouchDown
|
||||||
| InputKind::TouchMove
|
| InputKind::TouchMove
|
||||||
| InputKind::TouchUp => Ok(()),
|
| InputKind::TouchUp => Ok(()),
|
||||||
|
|||||||
@@ -84,12 +84,24 @@ struct Pending {
|
|||||||
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
|
||||||
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
|
||||||
parked: bool,
|
parked: bool,
|
||||||
|
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
|
||||||
|
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
|
||||||
|
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
|
||||||
|
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
|
||||||
|
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
|
||||||
|
knock_seq: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct PendingState {
|
struct PendingState {
|
||||||
next_id: u32,
|
next_id: u32,
|
||||||
items: Vec<Pending>,
|
items: Vec<Pending>,
|
||||||
|
/// Fingerprint → the knock generation an approval admitted, kept briefly after [`NativePairing::add`]
|
||||||
|
/// clears the pending entry. Closes the last double-admit window: a superseded waiter that only
|
||||||
|
/// polls AFTER the approval (entry gone, fingerprint paired) can't tell it lost from the entry
|
||||||
|
/// alone — this marker lets it resolve `Superseded` instead of a second `Approved`. Pruned on
|
||||||
|
/// the pending TTL and overwritten per fingerprint, so it stays a handful of tuples.
|
||||||
|
admitted: Vec<(String, u32, Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pending-approval snapshot for the management API / web console.
|
/// A pending-approval snapshot for the management API / web console.
|
||||||
@@ -114,6 +126,10 @@ pub enum PairingDecision {
|
|||||||
Denied,
|
Denied,
|
||||||
/// No decision within the wait window — reject; the device can knock again.
|
/// No decision within the wait window — reject; the device can knock again.
|
||||||
TimedOut,
|
TimedOut,
|
||||||
|
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
|
||||||
|
/// newest parked connection is the one an approval admits (a retrying client abandons its
|
||||||
|
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
|
||||||
|
Superseded,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||||
@@ -353,9 +369,25 @@ impl NativePairing {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A device that knocked and is now paired shouldn't linger in the approval list.
|
// A device that knocked and is now paired shouldn't linger in the approval list. Record
|
||||||
|
// WHICH knock generation this pairing admits before clearing the entry: only the waiter
|
||||||
|
// holding that generation may return `Approved`; a superseded sibling that polls after the
|
||||||
|
// clear resolves `Superseded` off this marker (exactly-one-admission — see `admitted`).
|
||||||
{
|
{
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
let admitted_seq = pending
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||||
|
.map(|p| p.knock_seq);
|
||||||
|
if let Some(seq) = admitted_seq {
|
||||||
|
pending
|
||||||
|
.admitted
|
||||||
|
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||||
|
pending
|
||||||
|
.admitted
|
||||||
|
.push((fp_hex.to_string(), seq, Instant::now()));
|
||||||
|
}
|
||||||
pending
|
pending
|
||||||
.items
|
.items
|
||||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||||
@@ -394,11 +426,16 @@ impl NativePairing {
|
|||||||
|
|
||||||
// -- Delegated approval (roadmap §8b-1) --------------------------------
|
// -- Delegated approval (roadmap §8b-1) --------------------------------
|
||||||
|
|
||||||
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
|
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). The
|
||||||
|
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
|
||||||
|
/// could still be parked, which is bounded by the approval wait (well under the TTL).
|
||||||
fn expire_pending(pending: &mut PendingState) {
|
fn expire_pending(pending: &mut PendingState) {
|
||||||
pending
|
pending
|
||||||
.items
|
.items
|
||||||
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||||
|
pending
|
||||||
|
.admitted
|
||||||
|
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
|
||||||
@@ -419,12 +456,14 @@ impl NativePairing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||||
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list). A
|
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
|
||||||
|
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
|
||||||
|
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
|
||||||
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
|
||||||
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
|
||||||
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
|
||||||
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
|
||||||
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) {
|
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
|
||||||
let name = sanitize_device_name(name, fp_hex);
|
let name = sanitize_device_name(name, fp_hex);
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
Self::expire_pending(&mut pending);
|
Self::expire_pending(&mut pending);
|
||||||
@@ -438,8 +477,19 @@ impl NativePairing {
|
|||||||
if p.src_ip.is_none() {
|
if p.src_ip.is_none() {
|
||||||
p.src_ip = src_ip;
|
p.src_ip = src_ip;
|
||||||
}
|
}
|
||||||
return;
|
p.knock_seq = p.knock_seq.wrapping_add(1);
|
||||||
|
let seq = p.knock_seq;
|
||||||
|
drop(pending);
|
||||||
|
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
|
||||||
|
// holding its dead connection open until the approval window lapses.
|
||||||
|
self.changed.notify_waiters();
|
||||||
|
return seq;
|
||||||
}
|
}
|
||||||
|
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
|
||||||
|
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
|
||||||
|
pending
|
||||||
|
.admitted
|
||||||
|
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
|
||||||
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
|
||||||
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
// own oldest entry first so it can't crowd out other devices' knocks (#13).
|
||||||
if let Some(ip) = src_ip {
|
if let Some(ip) = src_ip {
|
||||||
@@ -471,22 +521,47 @@ impl NativePairing {
|
|||||||
requested_at: Instant::now(),
|
requested_at: Instant::now(),
|
||||||
src_ip,
|
src_ip,
|
||||||
parked: false,
|
parked: false,
|
||||||
|
knock_seq: 0,
|
||||||
});
|
});
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
|
||||||
/// A parked entry is protected from eviction under load (#13).
|
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
|
||||||
fn set_parked(&self, fp_hex: &str, parked: bool) {
|
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
|
||||||
|
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
if let Some(p) = pending
|
if let Some(p) = pending
|
||||||
.items
|
.items
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
|
||||||
{
|
{
|
||||||
p.parked = parked;
|
p.parked = parked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
|
||||||
|
/// compares this against its own generation to detect it was superseded by a re-knock.
|
||||||
|
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
|
||||||
|
let pending = self.pending.lock().unwrap();
|
||||||
|
pending
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||||
|
.map(|p| p.knock_seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
|
||||||
|
/// [`PendingState::admitted`]).
|
||||||
|
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
|
||||||
|
let pending = self.pending.lock().unwrap();
|
||||||
|
pending
|
||||||
|
.admitted
|
||||||
|
.iter()
|
||||||
|
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
|
||||||
|
.map(|(_, seq, _)| *seq)
|
||||||
|
}
|
||||||
|
|
||||||
/// The devices currently awaiting approval (for the management API).
|
/// The devices currently awaiting approval (for the management API).
|
||||||
pub fn pending(&self) -> Vec<PendingRequest> {
|
pub fn pending(&self) -> Vec<PendingRequest> {
|
||||||
let mut pending = self.pending.lock().unwrap();
|
let mut pending = self.pending.lock().unwrap();
|
||||||
@@ -560,29 +635,41 @@ impl NativePairing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||||
|
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
|
||||||
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
||||||
/// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is
|
/// approve or a concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer
|
||||||
/// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no
|
/// knock from the same fingerprint replaces this one (a retrying client — only the newest
|
||||||
/// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`]
|
/// connection is admitted; three siblings admitted at once has crashed gnome-shell live),
|
||||||
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and
|
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
|
||||||
/// streams with no reconnect (delegated approval, roadmap §8b-1).
|
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
|
||||||
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
|
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
|
||||||
|
/// connection open until a human clicks Approve — so the device pairs and streams with no
|
||||||
|
/// reconnect (delegated approval, roadmap §8b-1).
|
||||||
|
pub async fn wait_for_decision(
|
||||||
|
&self,
|
||||||
|
fp_hex: &str,
|
||||||
|
knock_seq: u32,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> PairingDecision {
|
||||||
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
|
||||||
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
// connection out of the pending queue while the operator decides (#13). Cleared on every
|
||||||
// exit path by the guard's Drop.
|
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
|
||||||
self.set_parked(fp_hex, true);
|
// unmarks the newer waiter's flag).
|
||||||
|
self.set_parked(fp_hex, knock_seq, true);
|
||||||
struct ParkGuard<'a> {
|
struct ParkGuard<'a> {
|
||||||
np: &'a NativePairing,
|
np: &'a NativePairing,
|
||||||
fp: &'a str,
|
fp: &'a str,
|
||||||
|
seq: u32,
|
||||||
}
|
}
|
||||||
impl Drop for ParkGuard<'_> {
|
impl Drop for ParkGuard<'_> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.np.set_parked(self.fp, false);
|
self.np.set_parked(self.fp, self.seq, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _park = ParkGuard {
|
let _park = ParkGuard {
|
||||||
np: self,
|
np: self,
|
||||||
fp: fp_hex,
|
fp: fp_hex,
|
||||||
|
seq: knock_seq,
|
||||||
};
|
};
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
@@ -592,16 +679,33 @@ impl NativePairing {
|
|||||||
tokio::pin!(notified);
|
tokio::pin!(notified);
|
||||||
notified.as_mut().enable();
|
notified.as_mut().enable();
|
||||||
|
|
||||||
|
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
|
||||||
|
// must never be admitted — not even if the approval lands before we wake.
|
||||||
|
match self.knock_seq_of(fp_hex) {
|
||||||
|
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
if self.is_paired(fp_hex) {
|
if self.is_paired(fp_hex) {
|
||||||
return PairingDecision::Approved;
|
// Paired with the pending entry already cleared: make sure the approval admitted
|
||||||
|
// OUR generation. A superseded waiter that first polls after `add()` sees the same
|
||||||
|
// paired/no-entry state as the winner — the admitted marker breaks the tie.
|
||||||
|
match self.admitted_seq(fp_hex) {
|
||||||
|
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||||
|
_ => return PairingDecision::Approved,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !self.pending_contains(fp_hex) {
|
if !self.pending_contains(fp_hex) {
|
||||||
// Neither pending nor paired. This is almost always a denial — but it can also be
|
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||||
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
||||||
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
||||||
// cleared-pending observation that is really an approval will now read as paired.
|
// cleared-pending observation that is really an approval will now read as paired —
|
||||||
|
// with the same generation tie-break as above (the admitted marker is written in
|
||||||
|
// the same critical section that clears the entry).
|
||||||
if self.is_paired(fp_hex) {
|
if self.is_paired(fp_hex) {
|
||||||
return PairingDecision::Approved;
|
match self.admitted_seq(fp_hex) {
|
||||||
|
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
|
||||||
|
_ => return PairingDecision::Approved,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return PairingDecision::Denied;
|
return PairingDecision::Denied;
|
||||||
}
|
}
|
||||||
@@ -781,19 +885,19 @@ mod tests {
|
|||||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||||
|
|
||||||
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
||||||
np.note_pending("Knocker", "ab01", None);
|
let seq = np.note_pending("Knocker", "ab01", None);
|
||||||
let d = np
|
let d = np
|
||||||
.wait_for_decision("ab01", Duration::from_millis(80))
|
.wait_for_decision("ab01", seq, Duration::from_millis(80))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(d, PairingDecision::TimedOut);
|
assert_eq!(d, PairingDecision::TimedOut);
|
||||||
assert!(np.pending_contains("ab01"));
|
assert!(np.pending_contains("ab01"));
|
||||||
|
|
||||||
// Approved: approving WHILE parked wakes the waiter with Approved.
|
// Approved: approving WHILE parked wakes the waiter with Approved.
|
||||||
let np2 = np.clone();
|
let np2 = np.clone();
|
||||||
let waiter =
|
let waiter = tokio::spawn(async move {
|
||||||
tokio::spawn(
|
np2.wait_for_decision("ab01", seq, Duration::from_secs(5))
|
||||||
async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await },
|
.await
|
||||||
);
|
});
|
||||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
let id = np
|
let id = np
|
||||||
.pending()
|
.pending()
|
||||||
@@ -806,12 +910,12 @@ mod tests {
|
|||||||
assert!(np.is_paired("ab01"));
|
assert!(np.is_paired("ab01"));
|
||||||
|
|
||||||
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
||||||
np.note_pending("Knock2", "cd02", None);
|
let seq = np.note_pending("Knock2", "cd02", None);
|
||||||
let np3 = np.clone();
|
let np3 = np.clone();
|
||||||
let waiter =
|
let waiter = tokio::spawn(async move {
|
||||||
tokio::spawn(
|
np3.wait_for_decision("cd02", seq, Duration::from_secs(5))
|
||||||
async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await },
|
.await
|
||||||
);
|
});
|
||||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
let id = np
|
let id = np
|
||||||
.pending()
|
.pending()
|
||||||
@@ -823,12 +927,67 @@ mod tests {
|
|||||||
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
|
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
|
||||||
assert!(!np.is_paired("cd02"));
|
assert!(!np.is_paired("cd02"));
|
||||||
|
|
||||||
// Already paired before the call → immediate Approved (no waiting).
|
// Already paired before the call (the PIN-ceremony race) → immediate Approved: the ab01
|
||||||
let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await;
|
// marker admitted generation 0, which is also what a fresh coincidental waiter holds.
|
||||||
|
let d = np
|
||||||
|
.wait_for_decision("ab01", 0, Duration::from_secs(5))
|
||||||
|
.await;
|
||||||
assert_eq!(d, PairingDecision::Approved);
|
assert_eq!(d, PairingDecision::Approved);
|
||||||
let _ = std::fs::remove_file(&p);
|
let _ = std::fs::remove_file(&p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Approve must admit exactly ONE session: a re-knock supersedes the previous parked
|
||||||
|
/// waiter (it resolves `Superseded` immediately, not at timeout), the console list keeps a
|
||||||
|
/// single entry, and a stale-generation waiter that polls only AFTER the approval still
|
||||||
|
/// resolves `Superseded` off the admitted marker. (Live failure this pins down: a client
|
||||||
|
/// knocked 3×, one Approve admitted all three, and the three concurrent Mutter virtual
|
||||||
|
/// monitors segfaulted gnome-shell.)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newest_knock_supersedes_parked_waiter() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||||
|
|
||||||
|
let seq1 = np.note_pending("iPad Pro", "ee01", None);
|
||||||
|
let np1 = np.clone();
|
||||||
|
let waiter1 = tokio::spawn(async move {
|
||||||
|
np1.wait_for_decision("ee01", seq1, Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
|
||||||
|
// The device retries: same fingerprint, new connection. The old waiter is superseded at
|
||||||
|
// once; the pending list still shows ONE entry.
|
||||||
|
let seq2 = np.note_pending("iPad Pro", "ee01", None);
|
||||||
|
assert_ne!(seq1, seq2);
|
||||||
|
assert_eq!(waiter1.await.unwrap(), PairingDecision::Superseded);
|
||||||
|
assert_eq!(np.pending().len(), 1);
|
||||||
|
|
||||||
|
let np2 = np.clone();
|
||||||
|
let waiter2 = tokio::spawn(async move {
|
||||||
|
np2.wait_for_decision("ee01", seq2, Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
let id = np
|
||||||
|
.pending()
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.fingerprint == "ee01")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
np.approve_pending(id, None).unwrap().unwrap();
|
||||||
|
assert_eq!(waiter2.await.unwrap(), PairingDecision::Approved);
|
||||||
|
|
||||||
|
// A stale-generation waiter polling only after the approval (entry cleared, fingerprint
|
||||||
|
// paired) must NOT read as a second Approved — the admitted marker resolves the tie.
|
||||||
|
let d = np
|
||||||
|
.wait_for_decision("ee01", seq1, Duration::from_millis(80))
|
||||||
|
.await;
|
||||||
|
assert_eq!(d, PairingDecision::Superseded);
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
|
||||||
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
|
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
|
||||||
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
|
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -873,8 +1032,8 @@ mod tests {
|
|||||||
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood
|
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood
|
||||||
// from many distinct IPs that fills the global cap.
|
// from many distinct IPs that fills the global cap.
|
||||||
let legit = IpAddr::from([192, 168, 1, 50]);
|
let legit = IpAddr::from([192, 168, 1, 50]);
|
||||||
np.note_pending("Living Room", "legit01", Some(legit));
|
let seq = np.note_pending("Living Room", "legit01", Some(legit));
|
||||||
np.set_parked("legit01", true);
|
np.set_parked("legit01", seq, true);
|
||||||
for i in 0..(PENDING_CAP * 2) {
|
for i in 0..(PENDING_CAP * 2) {
|
||||||
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
|
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
|
||||||
np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
|
np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
|
||||||
|
|||||||
@@ -700,13 +700,16 @@ async fn serve_session(
|
|||||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||||
"unpaired device knocked — parking connection for delegated approval in the console");
|
"unpaired device knocked — parking connection for delegated approval in the console");
|
||||||
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
|
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
|
||||||
// host from flooding/evicting genuine knocks (#13).
|
// host from flooding/evicting genuine knocks (#13). The returned knock generation makes
|
||||||
np.note_pending(&label, &fp_hex, Some(peer.ip()));
|
// this connection the ONE an approval admits — a retrying client parks a fresh
|
||||||
|
// connection per knock, and admitting every parked sibling on a single Approve spun up
|
||||||
|
// three concurrent Mutter virtual monitors and segfaulted gnome-shell (2026-07-10).
|
||||||
|
let knock_seq = np.note_pending(&label, &fp_hex, Some(peer.ip()));
|
||||||
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
||||||
// permit (a handful of parked knocks would otherwise block every real session).
|
// permit (a handful of parked knocks would otherwise block every real session).
|
||||||
drop(permit);
|
drop(permit);
|
||||||
let decision = tokio::select! {
|
let decision = tokio::select! {
|
||||||
d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d,
|
d = np.wait_for_decision(&fp_hex, knock_seq, PENDING_APPROVAL_WAIT) => d,
|
||||||
// The client gave up (closed the connection) before a decision — stop waiting.
|
// The client gave up (closed the connection) before a decision — stop waiting.
|
||||||
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
|
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
|
||||||
};
|
};
|
||||||
@@ -720,6 +723,10 @@ async fn serve_session(
|
|||||||
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
||||||
— the device can knock again"
|
— the device can knock again"
|
||||||
),
|
),
|
||||||
|
PairingDecision::Superseded => anyhow::bail!(
|
||||||
|
"parked knock superseded by a newer connection from the same device — \
|
||||||
|
only the newest is admitted on approval"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
|
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
|
||||||
// exactly like any freshly accepted client).
|
// exactly like any freshly accepted client).
|
||||||
@@ -1038,6 +1045,9 @@ async fn serve_session(
|
|||||||
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
||||||
// assuming HEVC.
|
// assuming HEVC.
|
||||||
codec: codec_bit,
|
codec: codec_bit,
|
||||||
|
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||||
|
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||||
|
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||||
};
|
};
|
||||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||||
|
|
||||||
@@ -1537,7 +1547,8 @@ async fn serve_session(
|
|||||||
|
|
||||||
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
||||||
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
||||||
#[derive(Clone, Copy, Default)]
|
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
struct PadState {
|
struct PadState {
|
||||||
buttons: u32,
|
buttons: u32,
|
||||||
left_trigger: u8,
|
left_trigger: u8,
|
||||||
@@ -1574,6 +1585,17 @@ impl PadState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
|
||||||
|
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
|
||||||
|
self.buttons = s.buttons;
|
||||||
|
self.left_trigger = s.left_trigger;
|
||||||
|
self.right_trigger = s.right_trigger;
|
||||||
|
self.ls_x = s.ls_x;
|
||||||
|
self.ls_y = s.ls_y;
|
||||||
|
self.rs_x = s.rs_x;
|
||||||
|
self.rs_y = s.rs_y;
|
||||||
|
}
|
||||||
|
|
||||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
||||||
crate::gamestream::gamepad::GamepadFrame {
|
crate::gamestream::gamepad::GamepadFrame {
|
||||||
index: index as i16,
|
index: index as i16,
|
||||||
@@ -1589,9 +1611,9 @@ impl PadState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highest pad index addressable on the wire (`flags` field); the uinput manager caps
|
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
|
||||||
/// actual pad creation at its own MAX_PADS.
|
/// manager caps actual pad creation at its own MAX_PADS.
|
||||||
const MAX_WIRE_PADS: usize = 16;
|
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||||
|
|
||||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||||
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
|
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
|
||||||
@@ -1793,6 +1815,9 @@ fn input_thread(
|
|||||||
let mut motion_window = std::time::Instant::now();
|
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;
|
||||||
|
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||||
|
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||||
|
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||||
// 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),
|
||||||
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
|
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
|
||||||
// transition (including a stop) heals on the next refresh.
|
// transition (including a stop) heals on the next refresh.
|
||||||
@@ -1859,6 +1884,32 @@ fn input_thread(
|
|||||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
InputKind::GamepadState => {
|
||||||
|
// Idempotent full-state snapshot from a capable client (see
|
||||||
|
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
|
||||||
|
// a datagram the network reordered can't roll held state backwards. The
|
||||||
|
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
|
||||||
|
// the common case — skip the frame emit then (an XInput packet-number bump
|
||||||
|
// for identical state is pure churn), but always advance the gate.
|
||||||
|
use punktfunk_core::input::GamepadSnapshot;
|
||||||
|
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
|
||||||
|
let idx = snap.pad as usize;
|
||||||
|
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
|
||||||
|
{
|
||||||
|
pad_seq[idx] = Some(snap.seq);
|
||||||
|
let before = pad_state[idx];
|
||||||
|
pad_state[idx].set_snapshot(&snap);
|
||||||
|
let first = pad_mask & (1 << idx) == 0;
|
||||||
|
if first || pad_state[idx] != before {
|
||||||
|
pad_mask |= 1 << idx;
|
||||||
|
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||||
|
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
||||||
|
frame,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Track press/release so a mid-press disconnect can be undone below.
|
// Track press/release so a mid-press disconnect can be undone below.
|
||||||
match ev.kind {
|
match ev.kind {
|
||||||
@@ -2424,9 +2475,25 @@ fn resolve_compositor(
|
|||||||
return Ok(Compositor::Gamescope);
|
return Ok(Compositor::Gamescope);
|
||||||
}
|
}
|
||||||
let available = crate::vdisplay::available();
|
let available = crate::vdisplay::available();
|
||||||
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
|
let chosen = match pick_compositor(pref, &available, detected) {
|
||||||
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
Some(c) => c,
|
||||||
})?;
|
None => {
|
||||||
|
// No live session — the state a compositor crash leaves behind (gnome-shell
|
||||||
|
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||||
|
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||||
|
// its next knock lands in the recovered desktop.
|
||||||
|
if crate::vdisplay::try_recover_session() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"no live graphical session for this uid — host session recovery launched \
|
||||||
|
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"no usable compositor (no live graphical session for this uid; set \
|
||||||
|
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
if !overridden {
|
if !overridden {
|
||||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||||
@@ -4299,6 +4366,65 @@ fn build_pipeline(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||||
|
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||||
|
let mut state = PadState::default();
|
||||||
|
let mut last_seq: Option<u8> = None;
|
||||||
|
|
||||||
|
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
|
||||||
|
let axis = InputEvent {
|
||||||
|
kind: InputKind::GamepadAxis,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: gamepad::AXIS_LT,
|
||||||
|
x: 200,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
};
|
||||||
|
assert!(state.apply(&axis));
|
||||||
|
assert_eq!(state.left_trigger, 200);
|
||||||
|
|
||||||
|
let snap = GamepadSnapshot {
|
||||||
|
pad: 0,
|
||||||
|
seq: 1,
|
||||||
|
buttons: gamepad::BTN_A,
|
||||||
|
left_trigger: 255,
|
||||||
|
right_trigger: 0,
|
||||||
|
ls_x: 100,
|
||||||
|
ls_y: -100,
|
||||||
|
rs_x: 0,
|
||||||
|
rs_y: 0,
|
||||||
|
};
|
||||||
|
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
|
||||||
|
last_seq = Some(snap.seq);
|
||||||
|
state.set_snapshot(&snap);
|
||||||
|
assert_eq!(state.left_trigger, 255);
|
||||||
|
assert_eq!(state.buttons, gamepad::BTN_A);
|
||||||
|
assert_eq!((state.ls_x, state.ls_y), (100, -100));
|
||||||
|
|
||||||
|
// A reordered (stale) snapshot must not roll the trigger back.
|
||||||
|
let stale = GamepadSnapshot {
|
||||||
|
seq: 0,
|
||||||
|
left_trigger: 10,
|
||||||
|
..snap
|
||||||
|
};
|
||||||
|
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
|
||||||
|
|
||||||
|
// The unchanged-refresh case the input thread skips the frame emit for: identical
|
||||||
|
// payload with a newer seq compares equal after apply.
|
||||||
|
let refresh = GamepadSnapshot { seq: 2, ..snap };
|
||||||
|
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
|
||||||
|
let before = state;
|
||||||
|
state.set_snapshot(&refresh);
|
||||||
|
assert_eq!(state, before);
|
||||||
|
|
||||||
|
// The snapshot survives the wire roundtrip into the same PadState shape.
|
||||||
|
let dec =
|
||||||
|
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dec, snap);
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
||||||
/// what each `note` produced.
|
/// what each `note` produced.
|
||||||
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
||||||
|
|||||||
@@ -712,6 +712,17 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||||
}
|
}
|
||||||
|
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||||
|
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||||
|
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||||
|
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||||
|
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||||
|
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||||
|
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||||
|
if active.kind == ActiveKind::None {
|
||||||
|
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||||
|
std::env::remove_var("WAYLAND_DISPLAY");
|
||||||
|
}
|
||||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
@@ -721,6 +732,55 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||||
|
|
||||||
|
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
||||||
|
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
||||||
|
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
||||||
|
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
||||||
|
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
||||||
|
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
||||||
|
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
||||||
|
/// handshake error tell the client to simply retry.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
||||||
|
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
||||||
|
return true; // a launch is already in flight — the retry lands in the recovered session
|
||||||
|
}
|
||||||
|
match std::process::Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => {
|
||||||
|
*last = Some(std::time::Instant::now());
|
||||||
|
tracing::warn!(cmd = %cmd,
|
||||||
|
"no live graphical session — launched the operator's session-recovery command");
|
||||||
|
// Reap off-thread so the finished child never lingers as a zombie.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(cmd = %cmd, error = %e,
|
||||||
|
"session-recovery command failed to launch");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
||||||
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
||||||
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ const APPLY_TEMPORARY: u32 = 1;
|
|||||||
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
||||||
const CURSOR_EMBEDDED: u32 = 1;
|
const CURSOR_EMBEDDED: u32 = 1;
|
||||||
|
|
||||||
|
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||||
|
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
|
||||||
|
/// *concurrent* rebuilds have segfaulted gnome-shell on-glass twice now: the teardown-side race is
|
||||||
|
/// documented at the teardown below, and on 2026-07-10 three simultaneous session setups (three
|
||||||
|
/// `RecordVirtual` calls within ~200 µs plus an `ApplyMonitorsConfig`) crashed the shell inside
|
||||||
|
/// `meta_monitor_manager_rebuild` — dropping the box to the GDM greeter until a DM restart. One
|
||||||
|
/// mutation at a time also keeps [`wait_virtual_connector`] sound: with two virtual outputs
|
||||||
|
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
|
||||||
|
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
|
||||||
|
/// mutex — including across the awaits of its single-threaded setup future — is safe.
|
||||||
|
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||||
pub struct MutterDisplay {
|
pub struct MutterDisplay {
|
||||||
@@ -146,7 +158,10 @@ impl VirtualDisplay for MutterDisplay {
|
|||||||
})
|
})
|
||||||
.context("spawn Mutter virtual-output thread")?;
|
.context("spawn Mutter virtual-output thread")?;
|
||||||
|
|
||||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
// 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
|
||||||
|
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
|
||||||
|
// outwait it plus its own handshake before this fires.
|
||||||
|
let node_id = match setup_rx.recv_timeout(Duration::from_secs(45)) {
|
||||||
Ok(Ok(v)) => v,
|
Ok(Ok(v)) => v,
|
||||||
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
|
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
|
||||||
Err(_) => bail!("timed out creating the Mutter virtual monitor"),
|
Err(_) => bail!("timed out creating the Mutter virtual monitor"),
|
||||||
@@ -181,6 +196,11 @@ impl Drop for StopGuard {
|
|||||||
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
||||||
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
||||||
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||||
|
// TOPOLOGY_LOCK is deliberately held across the awaits of the setup/teardown sequences: each
|
||||||
|
// session owns this dedicated OS thread and its own single-future runtime, so the guard never
|
||||||
|
// blocks a shared executor — it blocks exactly the sibling session threads, which is the point
|
||||||
|
// (see TOPOLOGY_LOCK).
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
fn session_thread(
|
fn session_thread(
|
||||||
setup_tx: Sender<Result<u32, String>>,
|
setup_tx: Sender<Result<u32, String>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
@@ -201,6 +221,11 @@ fn session_thread(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
|
// The whole setup — pre-snapshot → RecordVirtual → ApplyMonitorsConfig — is one
|
||||||
|
// read-modify-write on Mutter's monitor state; hold TOPOLOGY_LOCK across it so concurrent
|
||||||
|
// sessions can't interleave rebuilds (gnome-shell SIGSEGV) or poison each other's
|
||||||
|
// connector diffs. Released before the keepalive park below.
|
||||||
|
let topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||||
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||||
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||||
@@ -288,6 +313,8 @@ fn session_thread(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(topology_guard);
|
||||||
|
|
||||||
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
||||||
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
||||||
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
||||||
@@ -317,6 +344,9 @@ fn session_thread(
|
|||||||
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||||
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
||||||
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||||
|
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
|
||||||
|
// sibling's teardown or setup can't interleave with the rebuild it causes.
|
||||||
|
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||||
drop(tracked);
|
drop(tracked);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
|||||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
||||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
||||||
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE
|
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE
|
||||||
/// key + target id + the adapter LUID the driver actually used.
|
/// key + target id + the IddCx DISPLAY adapter LUID from the ADD reply
|
||||||
|
/// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the driver reports its render
|
||||||
|
/// adapter only in the shared frame header).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||||
@@ -100,7 +102,14 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
|||||||
struct Monitor {
|
struct Monitor {
|
||||||
key: MonitorKey,
|
key: MonitorKey,
|
||||||
target_id: u32,
|
target_id: u32,
|
||||||
|
/// IddCx DISPLAY adapter LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`) —
|
||||||
|
/// NOT the render GPU the driver renders on (the driver reports that one in the shared frame
|
||||||
|
/// header only). Do not compare it against render-GPU picks.
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
|
/// The render-GPU pin handed to SET_RENDER_ADAPTER at this monitor's ADD (`None` = no GPU was
|
||||||
|
/// selectable). The pin is never re-issued on reuse, so this is what the driver still renders
|
||||||
|
/// on — [`warn_if_pick_moved`] compares the CURRENT pick against it.
|
||||||
|
render_pin: Option<LUID>,
|
||||||
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
|
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
|
||||||
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
|
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
|
||||||
wudf_pid: u32,
|
wudf_pid: u32,
|
||||||
@@ -475,6 +484,7 @@ impl VirtualDisplayManager {
|
|||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"virtual monitor reused (concurrent / reconfigure session)"
|
"virtual monitor reused (concurrent / reconfigure session)"
|
||||||
);
|
);
|
||||||
|
warn_if_pick_moved(mon);
|
||||||
return Ok(self.output_for(mon, quit));
|
return Ok(self.output_for(mon, quit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +497,7 @@ impl VirtualDisplayManager {
|
|||||||
backend = self.driver.name(),
|
backend = self.driver.name(),
|
||||||
"virtual monitor reused (reconnect to a kept monitor)"
|
"virtual monitor reused (reconnect to a kept monitor)"
|
||||||
);
|
);
|
||||||
|
warn_if_pick_moved(&mon);
|
||||||
if mon.mode != mode {
|
if mon.mode != mode {
|
||||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||||
// display topology. `mon` is the local monitor just moved out of the `Lingering`
|
// display topology. `mon` is the local monitor just moved out of the `Lingering`
|
||||||
@@ -562,13 +573,14 @@ impl VirtualDisplayManager {
|
|||||||
crate::vdisplay::policy::Identity::PerClient,
|
crate::vdisplay::policy::Identity::PerClient,
|
||||||
)
|
)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let render_pin = resolve_render_pin();
|
||||||
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
||||||
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
||||||
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
|
// `render_pin` is an `Option<LUID>` by value (plain `Copy`), so no borrowed memory
|
||||||
// memory crosses the call.
|
// crosses the call.
|
||||||
let added = unsafe {
|
let added = unsafe {
|
||||||
self.driver
|
self.driver
|
||||||
.add_monitor(dev, mode, resolve_render_pin(), preferred_id)?
|
.add_monitor(dev, mode, render_pin, preferred_id)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||||
@@ -724,6 +736,7 @@ impl VirtualDisplayManager {
|
|||||||
key: added.key,
|
key: added.key,
|
||||||
target_id: added.target_id,
|
target_id: added.target_id,
|
||||||
luid: added.luid,
|
luid: added.luid,
|
||||||
|
render_pin,
|
||||||
wudf_pid: added.wudf_pid,
|
wudf_pid: added.wudf_pid,
|
||||||
gdi_name,
|
gdi_name,
|
||||||
mode,
|
mode,
|
||||||
@@ -1033,6 +1046,36 @@ fn resolve_render_pin() -> Option<LUID> {
|
|||||||
crate::win_adapter::resolve_render_adapter_luid()
|
crate::win_adapter::resolve_render_adapter_luid()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reused monitor keeps the render GPU the driver was pinned to at its ADD — the pin is never
|
||||||
|
/// re-issued on reuse. When the current pick has moved since then (an operator preference change,
|
||||||
|
/// or the max-VRAM tie shifting on identical twin GPUs), say so: the session self-heals (the
|
||||||
|
/// IDD-push open rebinds its ring to the driver's actual adapter on a mismatch), but the new pick
|
||||||
|
/// only takes effect on the next monitor CREATE, which the mgmt `/display/release` forces.
|
||||||
|
/// Compares the pick against the ADD-time PIN — `mon.luid` is the IddCx display adapter and must
|
||||||
|
/// not be compared with render-GPU picks.
|
||||||
|
fn warn_if_pick_moved(mon: &Monitor) {
|
||||||
|
let Some(pin) = mon.render_pin else { return };
|
||||||
|
let Some(sel) = crate::gpu::selected_gpu() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pick = sel.info.luid();
|
||||||
|
if (pick.LowPart, pick.HighPart) != (pin.LowPart, pin.HighPart) {
|
||||||
|
tracing::warn!(
|
||||||
|
pinned_adapter = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||||
|
current_pick = format!(
|
||||||
|
"{:08x}:{:08x} ({}, {})",
|
||||||
|
pick.HighPart,
|
||||||
|
pick.LowPart,
|
||||||
|
sel.info.name,
|
||||||
|
sel.source.tag()
|
||||||
|
),
|
||||||
|
"reused virtual monitor is pinned to a different render GPU than the current pick — \
|
||||||
|
the session follows the pinned GPU; free the display (mgmt /display/release) to \
|
||||||
|
recreate it on the picked one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
||||||
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
||||||
/// maps it into the wire shape.
|
/// maps it into the wire shape.
|
||||||
|
|||||||
@@ -539,17 +539,12 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(pin) = render_luid {
|
// NOTE: `reply.adapter_luid` is the IddCx DISPLAY adapter
|
||||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`), NOT the render GPU, so it can NOT validate
|
||||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
// SET_RENDER_ADAPTER — a comparison against the pin here fired "DIFFERS from pinned" on
|
||||||
} else {
|
// every ADD (verified on-glass: reply 0x22c05 vs pin 0x15b05 on a single-4090 box). The
|
||||||
tracing::warn!(
|
// driver reports its ACTUAL render adapter in the shared frame header; the IDD-push
|
||||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
// capturer checks it there and rebinds on a mismatch.
|
||||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
|
||||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(AddedMonitor {
|
Ok(AddedMonitor {
|
||||||
key: MonitorKey::Session(session_id),
|
key: MonitorKey::Session(session_id),
|
||||||
target_id: reply.target_id,
|
target_id: reply.target_id,
|
||||||
|
|||||||
@@ -164,6 +164,10 @@
|
|||||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||||
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||||
|
|
||||||
|
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||||
|
// client's snapshot fold and the host's per-pad accumulators.
|
||||||
|
#define MAX_PADS 16
|
||||||
|
|
||||||
#define PUNKTFUNK_BTN_DPAD_UP 1
|
#define PUNKTFUNK_BTN_DPAD_UP 1
|
||||||
|
|
||||||
#define PUNKTFUNK_BTN_DPAD_DOWN 2
|
#define PUNKTFUNK_BTN_DPAD_DOWN 2
|
||||||
@@ -295,6 +299,15 @@
|
|||||||
#define APP_EXITED_CLOSE_CODE 82
|
#define APP_EXITED_CLOSE_CODE 82
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||||
|
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||||
|
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||||
|
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||||
|
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||||
|
#define HOST_CAP_GAMEPAD_STATE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
@@ -550,6 +563,17 @@ enum PunktfunkInputKind
|
|||||||
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10,
|
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10,
|
||||||
// Touch ends. Only `code` (the touch id) is used.
|
// Touch ends. Only `code` (the touch id) is used.
|
||||||
PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11,
|
PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11,
|
||||||
|
// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
|
||||||
|
//
|
||||||
|
// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
|
||||||
|
// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
|
||||||
|
// the host's accumulated pad state until the *next* change (a held trigger stays wrong
|
||||||
|
// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
|
||||||
|
// sequence number lets the host drop stale reorders — the same idempotent-state discipline
|
||||||
|
// as the host→client rumble refresh. Sent only when the host advertised
|
||||||
|
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||||
|
// receiving the per-transition events.
|
||||||
|
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12,
|
||||||
};
|
};
|
||||||
#ifndef __cplusplus
|
#ifndef __cplusplus
|
||||||
#if __STDC_VERSION__ >= 202311L
|
#if __STDC_VERSION__ >= 202311L
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
|||||||
# Lower (e.g. 3000) to reclaim kept displays sooner after an
|
# Lower (e.g. 3000) to reclaim kept displays sooner after an
|
||||||
# ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a
|
# ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a
|
||||||
# live session never false-disconnects. A deliberate quit is instant.
|
# live session never false-disconnects. A deliberate quit is instant.
|
||||||
|
# Session recovery hook: fired (debounced, ≥60 s apart) when a client connects while NO graphical
|
||||||
|
# session is live for this uid — e.g. a compositor crash dropped the box to the GDM greeter, whose
|
||||||
|
# auto-login only runs once per boot, so the box would otherwise need a walk-up login or a reboot.
|
||||||
|
# Restarting the display manager re-runs auto-login and the client's retry lands in the recovered
|
||||||
|
# desktop. Runs detached via `sh -c` as the host's user, so it needs passwordless privilege for
|
||||||
|
# exactly this action (sudoers drop-in: `youruser ALL=(root) NOPASSWD: /usr/bin/systemctl restart
|
||||||
|
# gdm`, or a polkit rule + plain `systemctl restart display-manager`). Unset = disabled.
|
||||||
|
#PUNKTFUNK_RECOVER_SESSION_CMD=sudo -n systemctl restart gdm
|
||||||
|
|
||||||
# Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on
|
# Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on
|
||||||
# the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else
|
# the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else
|
||||||
# the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path;
|
# the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path;
|
||||||
|
|||||||
Reference in New Issue
Block a user