diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index aa32d38..e61eb5a 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -250,7 +250,8 @@ fn connect_with( } gamepad.attach(connector.clone()); *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers - *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone())); + *shared.handoff.lock().unwrap() = + Some((connector, handle.frames.clone(), handle.stop.clone())); ss.call(Screen::Stream); } SessionEvent::Failed { diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 3b55283..ced16a6 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -95,10 +95,14 @@ impl PartialEq for Svc { } } -/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread). +/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread): +/// the connector (input sends), the decoded-frame channel (render thread), and the session's +/// stop flag (the disconnect shortcut trips it). #[derive(Default)] pub(crate) struct Shared { - pub(crate) handoff: Mutex, crate::session::FrameRx)>>, + #[allow(clippy::type_complexity)] + pub(crate) handoff: + Mutex, crate::session::FrameRx, Arc)>>, pub(crate) target: Mutex, /// Latest stream stats, written by the session's event loop and mirrored into reactor state /// by the HUD poll thread to drive the overlay. @@ -231,6 +235,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { set_hud.call(stream::HudSample { stats: *shared.stats.lock().unwrap(), captured: crate::input::is_captured(), + present: crate::render::present_stats(), }); }) .ok(); diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 7dc2db0..e77847f 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -166,6 +166,31 @@ pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState 1).then(|| { + let mut names = vec!["Automatic (the display's GPU)".to_string()]; + names.extend(gpus.iter().cloned()); + let current = gpus + .iter() + .position(|n| *n == s.adapter) + .map_or(0, |i| i + 1); + let gpus = gpus.clone(); + setting_combo( + ctx, + "GPU (decode + present, applies to the next stream)", + names, + current, + move |s, i| { + s.adapter = if i == 0 { + String::new() + } else { + gpus[i - 1].clone() + }; + }, + ) + }); let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec); let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| { s.codec = CODECS[i].0.to_string(); @@ -269,15 +294,17 @@ pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState = vec![decoder_combo.into()]; + if let Some(c) = gpu_combo { + controls.push(c.into()); + } + controls.extend([codec_combo.into(), bitrate_box.into(), hdr_toggle.into()]); + controls + }, ), section("INPUT"), settings_card( diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs index b5bb709..3e56742 100644 --- a/clients/windows/src/app/stream.rs +++ b/clients/windows/src/app/stream.rs @@ -15,12 +15,15 @@ use std::cell::RefCell; use std::sync::Arc; use windows_reactor::*; -/// One HUD refresh: the latest session stats plus the input hooks' capture state. Mirrored into -/// root state by the poll thread (`pf-hud`) and passed down as a prop. +/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render +/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and +/// passed down as a prop. #[derive(Clone, Copy, Default, PartialEq)] pub(crate) struct HudSample { pub(crate) stats: Stats, pub(crate) captured: bool, + /// `(presents/s, skipped/s, capture→presented p50 ms)` — see [`crate::render::present_stats`]. + pub(crate) present: (u32, u32, f32), } /// Props for the stream page: the services plus the live HUD sample that drives the overlay @@ -71,12 +74,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts; let connector_ref = connector_ref.clone(); move || { - if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() { + if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() { let mode = connector.mode(); let clock_offset = connector.clock_offset_ns; connector_ref.set(Some(connector.clone())); PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset))); - crate::input::install(connector, mode, inhibit); + crate::input::install(connector, mode, inhibit, stop); } Some(|| { RENDER.with(|c| { @@ -91,6 +94,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { }); let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); + let host = ctx.shared.target.lock().unwrap().name.clone(); grid(( swap_chain_panel() .on_ready(|panel| { @@ -128,7 +132,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { } }); }), - hud_overlay(&props.hud, mode), + hud_overlay(&props.hud, mode, &host), )) .into() } @@ -146,15 +150,39 @@ fn hud_chip(text: &str, color: Color) -> Border { .padding(edges(8.0, 2.0, 8.0, 2.0)) } -/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode -/// path · HDR), the fps/throughput/latency line, and the capture-state hint. Layered over the -/// `SwapChainPanel` in the same grid cell. -fn hud_overlay(hud: &HudSample, mode: Option) -> Element { +/// The negotiated wire codec's display name (`quic::CODEC_*` bit → label). +fn codec_name(bits: u8) -> &'static str { + match bits { + punktfunk_core::quic::CODEC_H264 => "H.264", + punktfunk_core::quic::CODEC_AV1 => "AV1", + _ => "HEVC", + } +} + +/// `mm:ss` (or `h:mm:ss`) session time. +fn fmt_uptime(secs: u32) -> String { + let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · codec · +/// decode path · HDR), a stream line (decode fps / bitrate / decode time), a glass line (display +/// presents + end-to-end latency decoded vs on-glass), a session line (host · time · loss), and +/// the shortcut hints. Layered over the `SwapChainPanel` in the same grid cell. +fn hud_overlay(hud: &HudSample, mode: Option, host: &str) -> Element { let stats = &hud.stats; + let (pfps, skipped, glass_ms) = hud.present; let res = mode .map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz)) .unwrap_or_else(|| "\u{2014}".into()); - let mut chips: Vec = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()]; + let mut chips: Vec = vec![ + hud_chip(&res, Color::rgb(235, 235, 235)).into(), + hud_chip(codec_name(stats.codec), Color::rgb(180, 190, 255)).into(), + ]; chips.push(if stats.hardware { hud_chip("GPU decode", Color::rgb(120, 220, 150)).into() } else { @@ -163,21 +191,43 @@ fn hud_overlay(hud: &HudSample, mode: Option) -> Element { if stats.hdr { chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into()); } - let line = format!( - "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms", - stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms + let stream_line = format!( + "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} decode {:.1} ms", + stats.fps, stats.mbps, stats.decode_ms ); + // End-to-end latency (host-clock corrected): capture→decoded from the pump, capture→on-glass + // from the render thread's post-Present stamp. `skipped` = newest-wins drops (expected when + // the stream outpaces the display); `lost` = unrecoverable network drops. + let glass_line = format!( + "display {pfps} fps \u{00B7} latency {:.1} ms decoded / {glass_ms:.1} ms on-glass", + stats.latency_ms + ); + let mut session_bits: Vec = Vec::new(); + if !host.is_empty() { + session_bits.push(host.to_string()); + } + session_bits.push(fmt_uptime(stats.uptime_secs)); + session_bits.push(format!("{} lost", stats.dropped)); + if skipped > 0 { + session_bits.push(format!("{skipped} skipped")); + } + let session_line = session_bits.join(" \u{00B7} "); let hint = if hud.captured { - "Ctrl+Alt+Shift+Q releases the mouse" + "Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects" } else { - "Click the stream to capture the mouse" + "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects" + }; + let dim = |t: &str| { + text_block(t) + .font_size(11.0) + .foreground(Color::rgb(210, 210, 210)) }; border( vstack(( hstack(chips).spacing(6.0), - text_block(line) - .font_size(11.0) - .foreground(Color::rgb(210, 210, 210)), + dim(&stream_line), + dim(&glass_line), + dim(&session_line), text_block(hint) .font_size(11.0) .foreground(Color::rgb(150, 150, 150)), diff --git a/clients/windows/src/gpu.rs b/clients/windows/src/gpu.rs index 216c55b..15707a8 100644 --- a/clients/windows/src/gpu.rs +++ b/clients/windows/src/gpu.rs @@ -8,11 +8,15 @@ //! session pump when it builds the decoder, or the UI thread when it builds the presenter). //! //! **Adapter selection** (matters on hybrid boxes — e.g. an Intel iGPU driving the panel next to -//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring) wins; else the -//! adapter whose output owns the monitor our window is on — that's the adapter DWM composes that -//! monitor with, so presents are copy-free and decode runs on the near GPU; else the default -//! adapter. Deliberately NOT "the adapter with the best decoder": if the monitor's adapter can't -//! decode the codec we demote to software, which beats a per-frame cross-adapter present copy. +//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring, a debugging +//! override) wins; else the persisted Settings GPU pick ([`crate::trust::Settings::adapter`], the +//! Settings-page selector on multi-GPU boxes); else the adapter whose output owns the monitor our +//! window is on — that's the adapter DWM composes that monitor with, so presents are copy-free +//! and decode runs on the near GPU; else the default adapter. Deliberately NOT "the adapter with +//! the best decoder": if the monitor's adapter can't decode the codec we demote to software, +//! which beats a per-frame cross-adapter present copy. The device is cached **keyed by the +//! resolved preference**, so a Settings change takes effect at the next session (the pump and the +//! presenter both resolve at session start and read the same value) without an app restart. //! //! `PUNKTFUNK_D3D_DEBUG=1` adds the D3D11 debug layer (validation messages in the debugger / //! DebugView) — invaluable for present-path bugs, which D3D11 otherwise drops silently. @@ -27,7 +31,7 @@ //! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage. use anyhow::{anyhow, Result}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex}; use windows::core::Interface; use windows::Win32::Graphics::Direct3D::{ D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP, @@ -55,17 +59,38 @@ pub struct SharedDevice { unsafe impl Send for SharedDevice {} unsafe impl Sync for SharedDevice {} -static SHARED: OnceLock> = OnceLock::new(); +/// The shared device, cached with the GPU preference it was resolved from (empty = automatic). +/// Re-created when the preference changes — in practice only between sessions: within one session +/// the decoder and the presenter both call [`shared`] at session start with the same value. +static SHARED: Mutex)>> = Mutex::new(None); -/// The process-wide shared D3D11 device, created on first call. `None` only if D3D11 device -/// creation fails for both a hardware adapter and WARP (effectively never — WARP is always present). -pub fn shared() -> Option<&'static SharedDevice> { - SHARED.get_or_init(create).as_ref() +/// The user's decode/present GPU preference: the `PUNKTFUNK_ADAPTER` env (debugging override) +/// wins, else the persisted Settings pick; empty = automatic. +fn adapter_pref() -> String { + std::env::var("PUNKTFUNK_ADAPTER") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| crate::trust::Settings::load().adapter) } -fn create() -> Option { - match create_device() { - Ok(d) => Some(d), +/// The process-shared D3D11 device for the current GPU preference, created (or re-created after +/// a preference change) on demand. `None` only if D3D11 device creation fails for both a hardware +/// adapter and WARP (effectively never — WARP is always present). +pub fn shared() -> Option> { + let pref = adapter_pref(); + let mut cached = SHARED.lock().unwrap(); + if let Some((key, dev)) = cached.as_ref() { + if *key == pref { + return Some(dev.clone()); + } + } + match create_device(&pref) { + Ok(d) => { + let d = Arc::new(d); + *cached = Some((pref, d.clone())); + Some(d) + } Err(e) => { tracing::error!(error = %e, "shared D3D11 device creation failed — no present/decode"); None @@ -87,25 +112,46 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String { } } -/// Resolve an explicit adapter: `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring) -/// wins; else the adapter whose output owns the monitor the app window is on (see module docs); -/// else `None` → the default adapter (also the headless-CLI path, where no window exists). -fn resolve_adapter() -> Option { - let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?; - let adapters: Vec = { - let mut v = Vec::new(); - let mut i = 0u32; - while let Ok(a) = unsafe { factory.EnumAdapters1(i) } { - i += 1; - if let Ok(a) = a.cast::() { - v.push(a); - } - } - v +/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=` uses these indices). +fn all_adapters() -> Vec { + let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } { + Ok(f) => f, + Err(_) => return Vec::new(), }; + let mut v = Vec::new(); + let mut i = 0u32; + while let Ok(a) = unsafe { factory.EnumAdapters1(i) } { + i += 1; + if let Ok(a) = a.cast::() { + v.push(a); + } + } + v +} - if let Ok(pref) = std::env::var("PUNKTFUNK_ADAPTER") { - let pref = pref.trim(); +/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list. +/// The picker only shows when this has more than one entry. +pub fn adapter_names() -> Vec { + const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set + all_adapters() + .iter() + .filter(|a| { + a.cast::() + .and_then(|a1| unsafe { a1.GetDesc1() }) + .map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0) + .unwrap_or(true) + }) + .map(adapter_name) + .collect() +} + +/// Resolve an explicit adapter: a non-empty `pref` (index or case-insensitive name substring, from +/// env or Settings) wins; else the adapter whose output owns the monitor the app window is on (see +/// module docs); else `None` → the default adapter (also the headless-CLI path with no window). +fn resolve_adapter(pref: &str) -> Option { + let adapters = all_adapters(); + + if !pref.is_empty() { let found = if let Ok(idx) = pref.parse::() { adapters.get(idx).cloned() } else { @@ -116,10 +162,8 @@ fn resolve_adapter() -> Option { .cloned() }; match &found { - Some(a) => { - tracing::info!(pref, adapter = %adapter_name(a), "PUNKTFUNK_ADAPTER matched") - } - None => tracing::warn!(pref, "PUNKTFUNK_ADAPTER matched no adapter — using default"), + Some(a) => tracing::info!(pref, adapter = %adapter_name(a), "GPU preference matched"), + None => tracing::warn!(pref, "GPU preference matched no adapter — using automatic"), } if found.is_some() { return found; @@ -153,12 +197,12 @@ fn resolve_adapter() -> Option { None } -fn create_device() -> Result { +fn create_device(pref: &str) -> Result { // Preference order: the resolved adapter (or the default hardware adapter) with video support // (enables D3D11VA); the same without the VIDEO flag (a driver that rejects it still presents + // software-decodes); finally WARP for the GPU-less box. BGRA_SUPPORT is required for the // composition swapchain in every case. An explicit adapter requires D3D_DRIVER_TYPE_UNKNOWN. - let adapter = resolve_adapter(); + let adapter = resolve_adapter(pref); let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter { Some(a) => [ (Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true), diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index 3ef168b..018388b 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -23,7 +23,9 @@ //! **click on the stream** re-engages it. Losing foreground also releases the lock so the cursor //! is never stranded; regaining it while still captured re-locks. When "capture system //! shortcuts" is off in Settings, Alt+Tab / Alt+Esc / Ctrl+Esc / the Win keys act on the local -//! desktop instead of being forwarded. +//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed +//! locally, works captured or released while our window is foreground): it trips the session's +//! stop flag, the pump winds down, and the event loop navigates back to the host list. use punktfunk_core::client::NativeClient; use punktfunk_core::config::Mode; @@ -34,7 +36,7 @@ use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; use windows::Win32::Graphics::Gdi::ClientToScreen; use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q; +use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_Q}; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, @@ -46,6 +48,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ struct State { connector: Arc, mode: Mode, + /// The session's stop flag (Ctrl+Alt+Shift+D trips it; the pump then ends the session). + stop: Arc, /// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not). hwnd: isize, /// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q / click-to-capture). @@ -99,11 +103,18 @@ fn set_captured(st: &mut State, on: bool) { /// Install the hooks for a streaming session. Call from the UI thread once the window is shown. /// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local. -pub fn install(connector: Arc, mode: Mode, inhibit_shortcuts: bool) { +/// `stop` is the session's stop flag, tripped by the disconnect shortcut. +pub fn install( + connector: Arc, + mode: Mode, + inhibit_shortcuts: bool, + stop: Arc, +) { let hwnd = unsafe { GetForegroundWindow() }; let mut st = State { connector, mode, + stop, hwnd: hwnd.0 as isize, captured: false, inhibit_shortcuts, @@ -266,6 +277,14 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)"); return LRESULT(1); } + // Disconnect: Ctrl+Alt+Shift+D (consumed locally). Release capture immediately so + // the cursor is free while the session winds down and the UI navigates home. + if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift { + set_captured(st, false); + st.stop.store(true, Ordering::SeqCst); + tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)"); + return LRESULT(1); + } if st.captured { // With shortcut capture off, hand Alt+Tab & co. to the local desktop — // neither forwarded nor swallowed. diff --git a/clients/windows/src/render.rs b/clients/windows/src/render.rs index 9a2974c..1c8aba5 100644 --- a/clients/windows/src/render.rs +++ b/clients/windows/src/render.rs @@ -16,6 +16,23 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +/// The last 1-second render window, published for the HUD (one render thread at a time): +/// presents/s, frames skipped by the newest-wins drain, and the capture→presented p50 in µs. +/// Zeroed when a render thread starts so a new session never shows the previous one's numbers. +static PRESENT_FPS: AtomicU32 = AtomicU32::new(0); +static PRESENT_SKIPPED: AtomicU32 = AtomicU32::new(0); +static PRESENT_P50_US: AtomicU64 = AtomicU64::new(0); + +/// `(presents/s, skipped/s, capture→presented p50 ms)` of the last render window — the HUD's +/// display-side line. +pub fn present_stats() -> (u32, u32, f32) { + ( + PRESENT_FPS.load(Ordering::Relaxed), + PRESENT_SKIPPED.load(Ordering::Relaxed), + PRESENT_P50_US.load(Ordering::Relaxed) as f32 / 1000.0, + ) +} + /// UI-thread → render-thread state. Size is packed into ONE atomic (w<<32|h) so a resize never /// tears into a (new-width, old-height) pair. pub struct RenderShared { @@ -133,6 +150,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc, clo let mut lat_us: Vec = Vec::with_capacity(256); let mut window_start = Instant::now(); let mut last_dpi_poll = Instant::now(); + PRESENT_FPS.store(0, Ordering::Relaxed); + PRESENT_SKIPPED.store(0, Ordering::Relaxed); + PRESENT_P50_US.store(0, Ordering::Relaxed); loop { if shared.stop.load(Ordering::SeqCst) { @@ -194,6 +214,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc, clo lat_us.sort_unstable(); let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0); tracing::debug!(presented, dropped, present_p50_us = p50, "render window"); + PRESENT_FPS.store(presented, Ordering::Relaxed); + PRESENT_SKIPPED.store(dropped, Ordering::Relaxed); + PRESENT_P50_US.store(p50, Ordering::Relaxed); window_start = Instant::now(); presented = 0; dropped = 0; diff --git a/clients/windows/src/session.rs b/clients/windows/src/session.rs index d634df9..9502c8d 100644 --- a/clients/windows/src/session.rs +++ b/clients/windows/src/session.rs @@ -51,10 +51,17 @@ pub struct Stats { pub decode_ms: f32, /// Median capture→decoded latency over the last window (host-clock corrected). pub latency_ms: f32, - /// True when decoding on the GPU (D3D11VA zero-copy) vs. CPU (software). + /// True when decoding on the GPU (D3D11VA) vs. CPU (software). pub hardware: bool, /// True when the stream is BT.2020 PQ HDR10 (last decoded frame). pub hdr: bool, + /// The negotiated wire codec (a `quic::CODEC_*` bit) — the HUD's codec chip. + pub codec: u8, + /// Frames lost to unrecoverable network drops since session start (reassembler count; each + /// triggers a keyframe re-request). + pub dropped: u64, + /// Seconds since the stream started. + pub uptime_secs: u32, } pub enum SessionEvent { @@ -299,6 +306,7 @@ fn pump( let clock_offset = connector.clock_offset_ns; let mut total_frames = 0u64; + let session_start = Instant::now(); let mut window_start = Instant::now(); let mut frames_n = 0u32; let mut bytes_n = 0u64; @@ -424,6 +432,9 @@ fn pump( latency_ms: p50 as f32 / 1000.0, hardware, hdr, + codec: connector.codec, + dropped: last_dropped, + uptime_secs: session_start.elapsed().as_secs() as u32, })); window_start = Instant::now(); frames_n = 0; diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index 6cbc304..a23e881 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -144,6 +144,11 @@ pub struct Settings { /// preference — the host honors it when it can emit it, else falls back to the best shared codec. #[serde(default = "default_codec")] pub codec: String, + /// Decode/present GPU: the DXGI adapter description to prefer on a multi-GPU box; empty = + /// automatic (the adapter driving the window's monitor). Applies from the next session; a + /// vanished adapter (eGPU unplugged) falls back to automatic. + #[serde(default)] + pub adapter: String, } fn default_codec() -> String { @@ -177,6 +182,7 @@ impl Default for Settings { hdr_enabled: true, decoder: "auto".into(), codec: "auto".into(), + adapter: String::new(), } } }