From ef5808254ae0a57688e3f50ab7b57cce50610b31 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 01:21:29 +0200 Subject: [PATCH] refactor(windows): remove the legacy in-process builtin stream path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real Windows client is the spawned punktfunk-session Vulkan binary (pf-client-core); the in-process builtin GUI stream — reachable only via PUNKTFUNK_BUILTIN_STREAM=1 — was dead weight kept alive by nothing and a recurring source of wasted effort. Remove it: delete present/render/input/ audio.rs and the builtin remainder of session/video.rs, rip all the builtin wiring (app/mod, connect, stream), and make connect always spawn. Preserve the two shipped keepers that happened to live in those files by relocating them to a new probe.rs: run_speed_probe (the per-host network speed test used by the Settings speed page and --headless --speed-test) and decodable_codecs (the codec-capability advert on the probe connect). Trim gpu.rs to just the Settings adapter picker (adapter_names + helpers). --headless now supports only --speed-test — the in-process decode/frame-counter went with the pump. Drops the now-orphaned deps opus, wasapi, crossbeam-channel, anyhow; keeps ffmpeg-next (probe::decodable_codecs still needs it). Net 4432 deletions. Statically verified (module wiring, imports, orphaned symbols/deps all clean); the type-level compile runs on the windows-amd64 CI runner, which has the toolchain this non-Windows host lacks. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/windows/Cargo.toml | 15 +- clients/windows/src/app/connect.rs | 210 +------ clients/windows/src/app/help.rs | 6 +- clients/windows/src/app/mod.rs | 57 +- clients/windows/src/app/settings.rs | 4 +- clients/windows/src/app/speed.rs | 2 +- clients/windows/src/app/stream.rs | 318 +--------- clients/windows/src/audio.rs | 308 ---------- clients/windows/src/gpu.rs | 237 +------ clients/windows/src/input.rs | 742 ---------------------- clients/windows/src/main.rs | 177 +----- clients/windows/src/present.rs | 915 ---------------------------- clients/windows/src/probe.rs | 79 +++ clients/windows/src/render.rs | 285 --------- clients/windows/src/session.rs | 555 ----------------- clients/windows/src/spawn.rs | 4 - clients/windows/src/trust.rs | 3 +- clients/windows/src/video.rs | 653 -------------------- 18 files changed, 138 insertions(+), 4432 deletions(-) delete mode 100644 clients/windows/src/audio.rs delete mode 100644 clients/windows/src/input.rs delete mode 100644 clients/windows/src/present.rs create mode 100644 clients/windows/src/probe.rs delete mode 100644 clients/windows/src/render.rs delete mode 100644 clients/windows/src/session.rs delete mode 100644 clients/windows/src/video.rs diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 54c08864..6db0d0de 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "punktfunk-client-windows" -description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads" +description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -57,13 +57,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 "Win32_UI_WindowsAndMessaging", ] } -# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev -# box; D3D11VA hardware decode is a follow-up for the real-GPU box. +# FFmpeg — used only to enumerate which codecs this client can decode (probe::decodable_codecs), +# advertised to the host on the speed-test connect. Same pin as the host/Linux client. (Real +# decode + present live in the spawned punktfunk-session binary.) ffmpeg-next = "8" -opus = "0.3" - -# Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend). -wasapi = "0.23" # Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; # built from source via the bundled CMake on Windows (no system SDL3). @@ -71,12 +68,8 @@ sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } mdns-sd = "0.20" async-channel = "2" -# The decoded-frame channel (session pump → render thread): crossbeam because the render loop -# blocks with `recv_timeout`, which async-channel has no sync analogue of. -crossbeam-channel = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" -anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 3261e610..f450bc05 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -6,10 +6,7 @@ use super::style::*; use super::{AppCtx, Screen, Svc, Target}; use crate::discovery::DiscoveredHost; -use crate::session::{self, SessionEvent, SessionParams, Stats}; -use crate::trust::{self, KnownHost, KnownHosts, Settings}; -use crate::video::DecoderPref; -use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; +use crate::trust::{self, KnownHost, KnownHosts}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -117,82 +114,6 @@ pub(crate) fn initiate_launch( ); } -/// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh -/// of the display our window is on (mirrors the Linux/Swift clients' native-display default). -pub(crate) fn resolve_mode(s: &Settings) -> Mode { - let mut mode = Mode { - width: s.width, - height: s.height, - refresh_hz: s.refresh_hz, - }; - if mode.width == 0 || mode.refresh_hz == 0 { - if let Some((w, h, hz)) = current_display_mode() { - if mode.width == 0 { - (mode.width, mode.height) = (w, h); - } - if mode.refresh_hz == 0 { - mode.refresh_hz = hz; - } - } - } - // No display info (headless session, RDP oddities) — a sane floor. - if mode.width == 0 { - (mode.width, mode.height) = (1920, 1080); - } - if mode.refresh_hz == 0 { - mode.refresh_hz = 60; - } - mode -} - -/// The current mode (physical pixels + refresh) of the display our window occupies: -/// `MonitorFromWindow` on the foreground window — ours, the user just clicked in it — then -/// `EnumDisplaySettingsW(ENUM_CURRENT_SETTINGS)` on that monitor's device. Defaults to the -/// primary display when we're not foreground (e.g. a scripted connect). -fn current_display_mode() -> Option<(u32, u32, u32)> { - use windows::core::PCWSTR; - use windows::Win32::Graphics::Gdi::{ - EnumDisplaySettingsW, GetMonitorInfoW, MonitorFromWindow, DEVMODEW, ENUM_CURRENT_SETTINGS, - MONITORINFO, MONITORINFOEXW, - }; - use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; - unsafe { - let monitor = MonitorFromWindow( - GetForegroundWindow(), - windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTOPRIMARY, - ); - let mut info = MONITORINFOEXW::default(); - info.monitorInfo.cbSize = std::mem::size_of::() as u32; - if !GetMonitorInfoW( - monitor, - &mut info as *mut MONITORINFOEXW as *mut MONITORINFO, - ) - .as_bool() - { - return None; - } - let mut dm = DEVMODEW { - dmSize: std::mem::size_of::() as u16, - ..Default::default() - }; - if !EnumDisplaySettingsW( - PCWSTR(info.szDevice.as_ptr()), - ENUM_CURRENT_SETTINGS, - &mut dm, - ) - .as_bool() - { - return None; - } - // dmDisplayFrequency of 0/1 means "hardware default" — unusable as a mode request. - (dm.dmPelsWidth > 0 && dm.dmDisplayFrequency > 1).then_some(( - dm.dmPelsWidth, - dm.dmPelsHeight, - dm.dmDisplayFrequency, - )) - } -} - /// Tunables that differ between the normal connect and the no-PIN "request access" flow. /// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the /// plain "Connecting" screen. @@ -220,9 +141,7 @@ pub(crate) struct ConnectOpts { /// so it can't loop. wake_on_fail: bool, /// A library title id (`steam:570`, …) the host launches during the connect handshake — - /// the library page's tap-to-play. Spawn mode passes it as `--launch`; the legacy - /// in-process path has no launch plumbing (it predates the library and is slated for - /// deletion). + /// the library page's tap-to-play, passed to the spawned session child as `--launch`. launch: Option, } @@ -265,128 +184,11 @@ fn connect_with( opts: ConnectOpts, ) { // Session-always: every stream runs in the spawned punktfunk-session Vulkan binary. - // The in-process D3D11VA path below stays reachable via the "Streaming engine" - // setting / PUNKTFUNK_BUILTIN_STREAM=1 as the A/B baseline until its deletion. - if !super::use_builtin_stream(ctx) { - return connect_spawn(ctx, target, pin, set_screen, set_status, opts); - } - let s = ctx.settings.lock().unwrap().clone(); - let gamepad_pref = match GamepadPref::from_name(&s.gamepad) { - Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(), - Some(explicit) => explicit, - }; - let handle = session::start(SessionParams { - host: target.addr.clone(), - port: target.port, - mode: resolve_mode(&s), - compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), - gamepad: gamepad_pref, - bitrate_kbps: s.bitrate_kbps, - audio_channels: s.audio_channels, - mic_enabled: s.mic_enabled, - hdr_enabled: s.hdr_enabled, - decoder: DecoderPref::from_name(&s.decoder), - preferred_codec: s.preferred_codec(), - pin, - identity: ctx.identity.clone(), - connect_timeout: opts.connect_timeout, - }); - set_status.call(String::new()); - set_screen.call(if opts.awaiting_approval { - Screen::RequestAccess - } else { - Screen::Connecting - }); - - let tofu = pin.is_none(); - let persist_paired = opts.persist_paired; - let cancel = opts.cancel; - let wake_on_fail = opts.wake_on_fail; - let ctx = ctx.clone(); - let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone()); - let (ss, st) = (set_screen.clone(), set_status.clone()); - let target = target.clone(); - std::thread::spawn(move || loop { - let event = match handle.events.recv_blocking() { - Ok(e) => e, - Err(_) => { - gamepad.detach(); - ss.call(Screen::Hosts); - break; - } - }; - // A cancelled request-access connect that resolved late (the host approved or the park - // timed out after the user walked away): tear down silently. Cancel already returned the - // UI to the host list; dropping `event` (and with it any connector) closes the connection - // without popping a stream or a stray error over the screen a new session may own. - if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { - break; - } - match event { - SessionEvent::Connected { - connector, - fingerprint, - .. - } => { - if persist_paired || tofu { - // Request-access: the operator approved this device, so record the host as a - // trusted PAIRED host — future connects are then silent (rule 1), exactly like - // after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned). - let mut k = KnownHosts::load(); - k.upsert(KnownHost { - name: target.name.clone(), - addr: target.addr.clone(), - port: target.port, - fp_hex: trust::hex(&fingerprint), - paired: persist_paired, - last_used: None, - mac: target.mac.clone(), - }); - let _ = k.save(); - } - trust::touch_last_used(&trust::hex(&fingerprint)); - 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(), handle.stop.clone())); - ss.call(Screen::Stream); - } - SessionEvent::Failed { - msg, - trust_rejected, - } => { - st.call(msg); - gamepad.detach(); - if trust_rejected { - // Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen. - // The host ANSWERED, so this never takes the wake fallback. - *shared.target.lock().unwrap() = target.clone(); - ss.call(Screen::Pair); - } else if wake_on_fail { - // The dial-first attempt to a non-advertising host failed — it may genuinely - // be asleep. NOW wake and wait (its resolved redial uses default opts, so a - // second failure lands on the host list, not back here). - wake_and_connect(&ctx, target.clone(), &ss, &st); - } else { - ss.call(Screen::Hosts); - } - break; - } - SessionEvent::Ended(err) => { - // `None` = the user ended the session themselves (the disconnect shortcut) — - // return to the host list silently; an error banner would read as a failure. - st.call(err.unwrap_or_default()); - gamepad.detach(); - ss.call(Screen::Hosts); - break; - } - SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s, - } - }); + connect_spawn(ctx, target, pin, set_screen, set_status, opts) } /// Spawn-mode connect: run the stream in the punktfunk-session binary and translate its -/// stdout contract into the same navigation the in-process event loop drove. The child +/// stdout contract into the app's connect-flow navigation. The child /// NEVER connects unpinned — a stored/ceremony pin, else the host's advertised /// fingerprint (TOFU: persisted once the child reports ready, which proves the host /// really holds that identity, mirroring the GTK shell); no fingerprint at all routes to @@ -723,9 +525,7 @@ pub(crate) fn request_access_page( .on_click(move || { // Return the UI immediately; trip the flag this request's event loop // captured so it tears down silently when the connect resolves (see - // ConnectOpts::cancel). Spawn mode: killing the parked child IS the abort - // (builtin mode's in-process connect is blocking with none — it just - // resolves/times out later). + // ConnectOpts::cancel). Killing the parked session child IS the abort. if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() { c.store(true, Ordering::SeqCst); } diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs index 372fcb57..301e6fd8 100644 --- a/clients/windows/src/app/help.rs +++ b/clients/windows/src/app/help.rs @@ -1,8 +1,7 @@ //! The Shortcuts screen: a short note on the in-stream capture model plus a reference of the //! keyboard shortcuts — reached from the Shortcuts button on the host list. The Windows //! counterpart of the GTK client's Keyboard Shortcuts window; the bindings themselves live in -//! the session window (and [`crate::input`] for the legacy builtin path), so both clients -//! document the same set. +//! the session window, so both clients document the same set. use super::style::*; use super::Screen; @@ -10,8 +9,7 @@ use windows_reactor::*; /// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it /// does. Read-only — the keyboard bindings live in the session window (`pf-presenter`'s run -/// loop; the legacy builtin path's in [`crate::input`]), the controller chord in its gamepad -/// service. +/// loop), the controller chord in its gamepad service. const STREAM_SHORTCUTS: &[(&str, &str)] = &[ ("F11 / Alt+Enter", "Toggle fullscreen"), ( diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 4ec12086..716e2e4d 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -1,8 +1,8 @@ //! The WinUI 3 (windows-reactor) application shell. //! //! Declarative React-like model: this root component routes on a `Screen` value held in -//! `use_async_state` so background threads (discovery, the session pump) can drive navigation. -//! Each screen lives in its own submodule: +//! `use_async_state` so background threads (discovery, the spawned session's stdout reader) can +//! drive navigation. Each screen lives in its own submodule: //! //! * [`hosts`] — saved/discovered/manual host list, plus per-host forget + speed test //! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows) @@ -10,7 +10,7 @@ //! * [`speed`] — the per-host network speed test (probe burst over the real data plane) //! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen · //! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list) -//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay +//! * [`stream`] — the stream status card (the stream itself runs in the spawned session window) //! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor //! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`) //! @@ -19,9 +19,6 @@ //! marks it dirty and re-renders it; an `AsyncSetState` written from a background thread does //! NOT (the child is pruned when its props are unchanged) — so everything thread-driven //! (discovery, HUD stats, speed-test results) is held as *root* state and passed down as props. -//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel -//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame -//! present must not go through state/rerender. mod connect; mod help; @@ -36,7 +33,6 @@ mod style; use crate::discovery::{self, DiscoveredHost}; use crate::gamepad::GamepadService; -use crate::session::Stats; use crate::trust::{KnownHosts, Settings}; use hosts::HostsProps; use punktfunk_core::client::NativeClient; @@ -45,7 +41,6 @@ use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::Duration; -use stream::StreamProps; use windows_reactor::*; #[derive(Clone, PartialEq)] @@ -88,7 +83,7 @@ pub(crate) struct Target { } /// Stable app services handed to the page components as props. Each routed screen that uses -/// hooks (`hosts_page`/`pair_page`/`stream_page`/`speed_page`) is mounted as its own +/// hooks (`hosts_page`/`pair_page`/`speed_page`/`library_page`) is mounted as its own /// `component(...)`, so its hooks live in an isolated slot list — calling them on the shared /// parent `cx` would change the hook order whenever the screen changes (reactor's /// Rules-of-Hooks guard aborts). @@ -115,18 +110,12 @@ impl PartialEq for Svc { } } -/// 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). +/// Cross-thread shell state driven off the UI thread: the current target, the live spawned +/// session child (Disconnect/Cancel kill it) and its latest stats line, plus the connect-flow +/// cancel flag and the discovery/library/speed-test generation guards. #[derive(Default)] pub(crate) struct Shared { - #[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. - pub(crate) stats: Mutex, /// The live session child (spawn mode) — the status page's Disconnect and the /// request-access Cancel kill it. A FRESH handle is installed per spawn. pub(crate) session: Mutex, @@ -157,14 +146,6 @@ pub struct AppCtx { pub(crate) shared: Arc, } -/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the -/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a -/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed -/// with the legacy path once the Vulkan session is fully validated. -pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool { - std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1") -} - pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { let ctx = Arc::new(AppCtx { identity, @@ -302,10 +283,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { } }); - // HUD sample: the session event loop writes `shared.stats` and the input hooks track capture - // state; this poll thread mirrors both into root state so the stream page gets them as a - // *prop* (thread-driven state must be root state — see the module docs). The compare in - // `AsyncSetState::call` makes the idle case free. + // HUD sample: the spawned session child's latest `stats:` line, mirrored into root state so + // the stream status page gets it as a *prop* (thread-driven state must be root state — see the + // module docs). The compare in `AsyncSetState::call` makes the idle case free. cx.use_effect((), { let shared = ctx.shared.clone(); let set_hud = set_hud.clone(); @@ -315,10 +295,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { .spawn(move || loop { std::thread::sleep(std::time::Duration::from_millis(400)); set_hud.call(stream::HudSample { - stats: *shared.stats.lock().unwrap(), - captured: crate::input::is_captured(), - visible: crate::input::hud_visible(), - present: crate::render::present_stats(), stats_line: shared.stats_line.lock().unwrap().clone(), }); }) @@ -525,16 +501,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { state: library, }, ), - // Spawn mode (the default): the stream runs in the punktfunk-session child's own - // window; this screen is a status page (no hooks — inline is sound). The legacy - // in-process SwapChainPanel page stays behind the "Streaming engine" setting / - // PUNKTFUNK_BUILTIN_STREAM=1. - Screen::Stream if !use_builtin_stream(ctx) => stream::session_page(ctx, &hud), - Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }), + // The stream runs in the punktfunk-session child's own window; this screen is a + // status page (no hooks — inline is sound). + Screen::Stream => stream::session_page(ctx, &hud), }; - // The Stream screen owns the SwapChainPanel + per-frame present; never wrap it in an animated - // opacity/offset layer. Everything else slides + fades in on navigation. + // The Stream screen is a plain status card (the session child owns the real stream window); + // it's shown without the navigation entrance tween. Everything else slides + fades in. if matches!(screen, Screen::Stream) { return body; } diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index eea03438..24b43686 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -302,8 +302,8 @@ pub(crate) fn settings_page( } else { keys.get(sel - 1).cloned() }; - // Apply live (the in-process service, legacy builtin streams) and persist — - // the spawned session reads `forward_pad` at connect. + // Apply live to the gamepad service and persist — the spawned session + // reads `forward_pad` at connect. svc.set_pinned(key.clone()); let mut s = ctx2.settings.lock().unwrap(); s.forward_pad = key.unwrap_or_default(); diff --git a/clients/windows/src/app/speed.rs b/clients/windows/src/app/speed.rs index caa7d348..5702708f 100644 --- a/clients/windows/src/app/speed.rs +++ b/clients/windows/src/app/speed.rs @@ -4,7 +4,7 @@ use super::style::*; use super::{Screen, Svc}; -use crate::session::run_speed_probe; +use crate::probe::run_speed_probe; use windows_reactor::*; /// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs index c64cef51..d3cc87bb 100644 --- a/clients/windows/src/app/stream.rs +++ b/clients/windows/src/app/stream.rs @@ -1,163 +1,20 @@ -//! The stream page: a `SwapChainPanel` whose composition swapchain is created (and bound) once on -//! the UI thread, then handed — presenter and all — to the dedicated render thread -//! ([`crate::render`]), which presents decoded frames at stream cadence. The page itself only -//! forwards panel size/DPI changes and draws the status-chip HUD overlay (mode · decode path · -//! HDR · fps/goodput · end-to-end latency + stage equation · capture hint). +//! The stream status page: streams run in the spawned `punktfunk-session` child's own window, +//! so the shell shows a status card in the app's card language — host header, the child's live +//! `stats:` line as a chip row + stage lines, the in-window shortcuts, and a Disconnect. use super::style::{edges, uniform}; -use super::Svc; -use crate::present::Presenter; -use crate::render::{self, RenderThread}; -use crate::session::Stats; -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::Mode; -use std::cell::RefCell; use std::sync::Arc; use windows_reactor::*; -/// 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. +/// One HUD refresh: the session child's latest formatted `stats:` line, mirrored into root state +/// by the poll thread (`pf-hud`) and passed down as a prop. #[derive(Clone, Default, PartialEq)] pub(crate) struct HudSample { - pub(crate) stats: Stats, - pub(crate) captured: bool, - /// Whether the stats overlay should be shown — the Settings default at stream start, then - /// whatever Ctrl+Alt+Shift+S last set (see [`crate::input::hud_visible`]). Carried in the - /// sample so a live toggle changes the sample and re-renders the page (the stream page is a - /// child component — only a changed prop re-renders it). - pub(crate) visible: bool, - /// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display - /// stage p50) — see [`crate::render::present_stats`]. - pub(crate) present: crate::render::PresentStats, - /// Spawn mode: the session child's latest formatted `stats:` line, for the status - /// page. Empty in builtin mode / before the first window. + /// The session child's latest formatted `stats:` line, for the status page. Empty before the + /// child's first stats window. pub(crate) stats_line: String, } -/// Props for the stream page: the services plus the live HUD sample that drives the overlay -/// (compared by value, so each new sample re-renders the overlay). -#[derive(Clone)] -pub(crate) struct StreamProps { - pub(crate) svc: Svc, - pub(crate) hud: HudSample, -} - -impl PartialEq for StreamProps { - fn eq(&self, other: &Self) -> bool { - self.svc == other.svc && self.hud == other.hud - } -} - -thread_local! { - /// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires - /// later, once the native panel exists). - static PENDING: RefCell)>> = const { RefCell::new(None) }; - /// The live render thread; stopped + joined by the unmount cleanup (before panel teardown). - static RENDER: RefCell> = const { RefCell::new(None) }; -} - -/// The app window's DPI (96 when the window can't be found — then DIPs == pixels). Reactor's -/// `on_resize` reports DIPs and exposes no CompositionScale, so the window DPI is the scale. -fn window_dpi() -> u32 { - use windows::Win32::UI::HiDpi::GetDpiForWindow; - use windows::Win32::UI::WindowsAndMessaging::FindWindowW; - unsafe { - FindWindowW(None, windows::core::w!("Punktfunk")) - .ok() - .map(|h| GetDpiForWindow(h)) - .filter(|d| *d > 0) - .unwrap_or(96) - } -} - -pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { - let ctx = &props.svc.ctx; - // Take the connector + frames handoff once on mount; keep the connector alive (and for input) - // in a use_ref, stash frames for `on_mounted`, install the input hooks. The cleanup stops the - // render thread FIRST (it must not present into a panel that's tearing down), then removes - // the input hooks. - let connector_ref = cx.use_ref::>>(None); - cx.use_effect_with_cleanup((), { - let shared = ctx.shared.clone(); - let (inhibit, show_stats) = { - let s = ctx.settings.lock().unwrap(); - (s.inhibit_shortcuts, s.show_stats) - }; - let connector_ref = connector_ref.clone(); - move || { - if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() { - let mode = connector.mode(); - let clock_offset = connector.clock_offset_shared(); - connector_ref.set(Some(connector.clone())); - PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset))); - crate::input::install(connector, mode, inhibit, show_stats, stop); - } - Some(|| { - RENDER.with(|c| { - if let Some(mut rt) = c.borrow_mut().take() { - rt.stop_and_join(); - } - }); - PENDING.with(|c| c.borrow_mut().take()); - crate::input::uninstall(); - }) - } - }); - - let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); - let host = ctx.shared.target.lock().unwrap().name.clone(); - let mut layers: Vec = vec![swap_chain_panel() - .on_mounted(|panel| { - // Placeholder size — the first `on_resize` (fired after the first layout pass) - // resizes to the panel's real pixel size. - let dpi = window_dpi(); - match Presenter::new(1280, 720, dpi) { - Ok(p) => { - if let Err(e) = panel.set_swap_chain(p.swap_chain()) { - tracing::error!(error = %e, "set_swap_chain"); - return; - } - if let Some((frames, clock_offset)) = PENDING.with(|c| c.borrow_mut().take()) { - let shared = render::RenderShared::new(1280, 720, dpi); - RENDER.with(|cell| { - *cell.borrow_mut() = - Some(render::spawn(p, frames, shared, clock_offset)); - }); - tracing::info!(dpi, "stream presenter bound — render thread started"); - } - } - Err(e) => tracing::error!(error = %e, "create presenter"), - } - }) - .on_resize(|w, h| { - // DIPs → physical pixels; the presenter maps back via SetMatrixTransform. - let dpi = window_dpi(); - let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32; - RENDER.with(|cell| { - if let Some(rt) = cell.borrow().as_ref() { - rt.shared().set_dpi(dpi); - rt.shared().set_size(px(w), px(h)); - } - }); - }) - .into()]; - // The overlay follows the LIVE visibility (Settings default, then Ctrl+Alt+Shift+S): the page - // re-renders on every HUD sample (~400 ms), so a toggle takes effect promptly mid-stream. - if props.hud.visible { - layers.push(hud_overlay(&props.hud, mode, &host)); - } - // Flash the shortcut key set for the first few seconds of every session, regardless of the - // HUD setting — so "how do I get back out" is answered the moment the stream comes up (parity - // with the GTK client's stream-start hint). Uptime drives it, so it needs no timer/state: the - // HUD poll re-renders the page each second and the banner drops once the session passes the - // threshold. - if props.hud.stats.uptime_secs < START_HINT_SECS { - layers.push(start_hint()); - } - grid(layers).into() -} - /// Spawn mode's Stream screen: the stream runs in the punktfunk-session child's own /// window, so the shell shows a status card in the app's card language — monogram + /// host header, the child's live `stats:` line as a chip row + stage lines, the @@ -277,164 +134,3 @@ pub(crate) fn session_page(ctx: &Arc, hud: &HudSample) -> Element .vertical_alignment(VerticalAlignment::Center) .into() } - -/// How long the stream-start shortcut banner stays up (seconds of session uptime). -const START_HINT_SECS: u32 = 6; - -/// The stream-start shortcut banner: the full client key set on a translucent pill, bottom-centre, -/// shown for [`START_HINT_SECS`] at the start of every session (see the call site). Independent of -/// the stats overlay, so it appears even with the HUD turned off. -fn start_hint() -> Element { - border( - text_block( - "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+Q releases \u{00B7} \ - Ctrl+Alt+Shift+D disconnects \u{00B7} Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen", - ) - .font_size(12.0) - .semibold() - .foreground(Color::rgb(235, 235, 235)), - ) - .background(Color::rgb(0, 0, 0)) - .corner_radius(10.0) - .padding(edges(14.0, 8.0, 14.0, 8.0)) - .opacity(0.82) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Bottom) - .margin(edges(0.0, 0.0, 0.0, 28.0)) - .into() -} - -/// A small chip for the dark HUD: coloured text on a translucent dark fill. -fn hud_chip(text: &str, color: Color) -> Border { - border( - text_block(text) - .font_size(11.0) - .semibold() - .foreground(color), - ) - .background(Color::rgb(38, 38, 38)) - .corner_radius(8.0) - .padding(edges(8.0, 2.0, 8.0, 2.0)) -} - -/// 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), unified stats vocabulary (design/stats-unification.md): -/// a chip row (mode · codec · decode path · HDR), a stream line (received fps · goodput · -/// presenter fps), the end-to-end headline (capture→on-glass p50/p95, host-clock corrected), the -/// stage equation (= host + network + decode + display when the host reports 0xCF timings, else -/// the combined = host+network + decode + display; stage p50s), a session line -/// (host · time · loss/skips), 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 present = &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(), - 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 { - hud_chip("CPU decode", Color::rgb(240, 190, 90)).into() - }); - if stats.hdr { - chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into()); - } - // Received fps + goodput, plus the presenter's own rate (Moonlight's "Rendering frame rate" - // analog — how often the display actually gets a new frame). - let stream_line = format!( - "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} display {} fps", - stats.fps, stats.mbps, present.fps - ); - // The headline: end-to-end capture→displayed, measured directly post-Present (never the sum - // of the stage percentiles). `(same-host clock)` flags an uncorrected clock (offset == 0: - // same host, or the host skipped the skew handshake). - let mut e2e_line = format!( - "end-to-end {:.1} ms p50 \u{00B7} {:.1} p95 \u{00B7} capture\u{2192}on-glass", - present.e2e_p50_ms, present.e2e_p95_ms - ); - if stats.same_host { - e2e_line.push_str(" (same-host clock)"); - } - // The equation: the stages tile the headline interval per frame; the window p50s only - // approximately sum (percentiles aren't additive). With per-AU 0xCF host timings the opaque - // `host+network` term splits into `host` (host capture→sent) + `network` (the remainder); - // an old host emits none and the combined term stays. - let stage_line = if stats.split { - format!( - "= host {:.1} + network {:.1} + decode {:.1} + display {:.1}", - stats.host_ms, stats.net_ms, stats.decode_ms, present.display_p50_ms - ) - } else { - format!( - "= host+network {:.1} + decode {:.1} + display {:.1}", - stats.hostnet_ms, stats.decode_ms, present.display_p50_ms - ) - }; - let mut session_bits: Vec = Vec::new(); - if !host.is_empty() { - session_bits.push(host.to_string()); - } - // `lost` = unrecoverable network drops (session-cumulative); `skipped` = the render thread's - // newest-wins drops last window (expected when the stream outpaces the display). - session_bits.push(fmt_uptime(stats.uptime_secs)); - session_bits.push(format!("{} lost", stats.dropped)); - if present.skipped > 0 { - session_bits.push(format!("{} skipped", present.skipped)); - } - let session_line = session_bits.join(" \u{00B7} "); - let hint = if hud.captured { - "Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \ - Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen" - } else { - "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \ - Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen" - }; - let dim = |t: &str| { - text_block(t) - .font_size(11.0) - .foreground(Color::rgb(210, 210, 210)) - }; - border( - vstack(( - hstack(chips).spacing(6.0), - dim(&stream_line), - dim(&e2e_line), - dim(&stage_line), - dim(&session_line), - text_block(hint) - .font_size(11.0) - .foreground(Color::rgb(150, 150, 150)), - )) - .spacing(6.0), - ) - .background(Color::rgb(0, 0, 0)) - .corner_radius(10.0) - .padding(uniform(10.0)) - .opacity(0.82) - .horizontal_alignment(HorizontalAlignment::Right) - .vertical_alignment(VerticalAlignment::Top) - .margin(uniform(12.0)) - .into() -} diff --git a/clients/windows/src/audio.rs b/clients/windows/src/audio.rs deleted file mode 100644 index 477f209f..00000000 --- a/clients/windows/src/audio.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Audio: playback (decoded PCM → a WASAPI shared-mode render stream) and the microphone -//! uplink (WASAPI capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic). -//! -//! The WASAPI analogue of the Linux client's PipeWire backend. Playback mirrors the host's -//! virtual-mic producer's adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded -//! chunks on the network clock; the WASAPI render thread pulls whole event-driven quanta on -//! the device clock. Prime to ~3 quanta before producing, cap the ring so latency stays -//! bounded, re-prime after a real drain. -//! -//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated thread -//! (the same discipline as the host's `wasapi_cap`); only the channel + stop flag + join -//! handle cross the boundary. - -use anyhow::{anyhow, Context, Result}; -use punktfunk_core::client::NativeClient; -use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, SyncSender, TrySendError}; -use std::sync::Arc; -use std::time::Duration; -use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat}; - -const SAMPLE_RATE: usize = 48_000; -/// The microphone uplink stays stereo (the host's virtual mic is stereo). The render path is -/// multichannel — its channel count + block align are runtime, driven by the host-resolved layout. -const CHANNELS: usize = 2; -/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side. -const MIC_FRAME: usize = 960; - -pub struct AudioPlayer { - pcm_tx: SyncSender>, - stop: Arc, - thread: Option>, -} - -impl AudioPlayer { - /// Spawn the WASAPI render thread for `channels` (2/6/8, canonical wire order - /// FL FR FC LFE RL RR SL SR). Failure (no render endpoint on this box) is survivable — the - /// caller streams video-only. - pub fn spawn(channels: u8) -> Result { - // 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop. - let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::>(64); - let stop = Arc::new(AtomicBool::new(false)); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); - let stop_t = stop.clone(); - let thread = std::thread::Builder::new() - .name("punktfunk-audio".into()) - .spawn(move || { - if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx, channels) { - tracing::warn!(error = format!("{e:#}"), "audio playback thread ended"); - } - }) - .context("spawn audio thread")?; - match ready_rx.recv_timeout(Duration::from_secs(3)) { - Ok(Ok(())) => { - tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)"); - Ok(AudioPlayer { - pcm_tx, - stop, - thread: Some(thread), - }) - } - Ok(Err(e)) => Err(e), - Err(_) => Err(anyhow!( - "wasapi render init timed out (no render endpoint?)" - )), - } - } - - /// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the - /// WASAPI side is wedged (the renderer conceals the gap; never block the session pump). - pub fn push(&self, pcm: Vec) { - if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) { - // Thread already dead — Drop will reap it; nothing to do per-chunk. - } - } -} - -impl Drop for AudioPlayer { - fn drop(&mut self) { - self.stop.store(true, Ordering::SeqCst); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - } -} - -fn render_thread( - pcm_rx: Receiver>, - stop: Arc, - ready: SyncSender>, - channels: u8, -) -> Result<()> { - if let Err(e) = wasapi::initialize_mta() - .ok() - .context("CoInitializeEx (MTA)") - { - let _ = ready.send(Err(e)); - return Ok(()); - } - let res = (|| -> Result<()> { - // F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical - // to the old fixed path (mask 0x3, block align 8). - let block_align = channels as usize * 4; - let device = DeviceEnumerator::new() - .context("DeviceEnumerator")? - .get_default_device(&Direction::Render) - .context("default render endpoint")?; - let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; - // The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F, - // 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire - // order, so the render mapping is the identity — no permute. `autoconvert` (below) lets the - // audio engine downmix when the endpoint has fewer speakers. - let desired = WaveFormat::new( - 32, - 32, - &SampleType::Float, - SAMPLE_RATE, - channels as usize, - Some(punktfunk_core::audio::wasapi_channel_mask(channels)), - ); - let (default_period, _min_period) = - audio_client.get_device_period().context("device period")?; - let mode = StreamMode::EventsShared { - autoconvert: true, - buffer_duration_hns: default_period, - }; - audio_client - .initialize_client(&desired, &Direction::Render, &mode) - .context("initialize render client")?; - let h_event = audio_client.set_get_eventhandle().context("event handle")?; - let render_client = audio_client - .get_audiorenderclient() - .context("IAudioRenderClient")?; - audio_client.start_stream().context("start render stream")?; - let _ = ready.send(Ok(())); - - // Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic). - let mut ring: VecDeque = VecDeque::new(); - let mut primed = false; - let mut out = Vec::new(); // per-quantum scratch, reused across iterations - - while !stop.load(Ordering::Relaxed) { - if h_event.wait_for_event(100).is_err() { - continue; - } - // Drain everything the pump has queued into the ring. - while let Ok(chunk) = pcm_rx.try_recv() { - for s in chunk { - ring.extend(s.to_le_bytes()); - } - } - let avail_frames = audio_client - .get_available_space_in_frames() - .context("available space")? as usize; - if avail_frames == 0 { - continue; - } - let want_bytes = avail_frames * block_align; - - // Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain. - let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align); - let cap = target.max(want_bytes) + want_bytes; - if ring.len() > cap { - ring.drain(..ring.len() - cap); - } - if !primed && ring.len() >= target { - primed = true; - } - - out.clear(); - out.resize(want_bytes, 0); - if primed { - let n = ring.len().min(want_bytes); - for (dst, b) in out.iter_mut().zip(ring.drain(..n)) { - *dst = b; - } - } - if ring.is_empty() { - primed = false; - } - render_client - .write_to_device(avail_frames, &out, None) - .context("write_to_device")?; - } - audio_client.stop_stream().ok(); - Ok(()) - })(); - if let Err(ref e) = res { - let _ = ready.send(Err(anyhow!("{e:#}"))); - } - res -} - -/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks, ship -/// them as 0xCB datagrams into the host's virtual mic source. -pub struct MicStreamer { - stop: Arc, - thread: Option>, -} - -impl MicStreamer { - pub fn spawn(connector: Arc) -> Result { - let stop = Arc::new(AtomicBool::new(false)); - let stop_t = stop.clone(); - let thread = std::thread::Builder::new() - .name("punktfunk-mic".into()) - .spawn(move || { - if let Err(e) = mic_thread(&connector, stop_t) { - tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended"); - } - }) - .context("spawn mic thread")?; - Ok(MicStreamer { - stop, - thread: Some(thread), - }) - } -} - -impl Drop for MicStreamer { - fn drop(&mut self) { - self.stop.store(true, Ordering::SeqCst); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - } -} - -fn mic_thread(connector: &Arc, stop: Arc) -> Result<()> { - wasapi::initialize_mta() - .ok() - .context("CoInitializeEx (MTA)")?; - - let mut encoder = opus::Encoder::new( - SAMPLE_RATE as u32, - opus::Channels::Stereo, - opus::Application::Voip, - ) - .map_err(|e| anyhow!("opus encoder: {e}"))?; - let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000)); - - let device = DeviceEnumerator::new() - .context("DeviceEnumerator")? - .get_default_device(&Direction::Capture) - .context("default capture endpoint (no microphone?)")?; - let mut audio_client = device.get_iaudioclient().context("IAudioClient")?; - let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None); - let (default_period, _min_period) = - audio_client.get_device_period().context("device period")?; - let mode = StreamMode::EventsShared { - autoconvert: true, - buffer_duration_hns: default_period, - }; - audio_client - .initialize_client(&desired, &Direction::Capture, &mode) - .context("initialize capture client")?; - let h_event = audio_client.set_get_eventhandle().context("event handle")?; - let capture_client = audio_client - .get_audiocaptureclient() - .context("IAudioCaptureClient")?; - audio_client - .start_stream() - .context("start capture stream")?; - - let mut bytes: VecDeque = VecDeque::new(); - let mut ring: VecDeque = VecDeque::new(); - let mut out = vec![0u8; 4000]; - let mut seq = 0u32; - - while !stop.load(Ordering::Relaxed) { - if h_event.wait_for_event(100).is_err() { - continue; - } - loop { - match capture_client.get_next_packet_size() { - Ok(Some(0)) | Ok(None) => break, - Ok(Some(_n)) => { - capture_client - .read_from_device_to_deque(&mut bytes) - .context("read capture")?; - } - Err(e) => return Err(anyhow!("get_next_packet_size: {e}")), - } - } - let whole = (bytes.len() / 4) * 4; - for c in bytes.drain(..whole).collect::>().chunks_exact(4) { - ring.push_back(f32::from_le_bytes([c[0], c[1], c[2], c[3]])); - } - // Ship every complete 20 ms stereo frame. - while ring.len() >= MIC_FRAME * CHANNELS { - let pcm: Vec = ring.drain(..MIC_FRAME * CHANNELS).collect(); - match encoder.encode_float(&pcm, &mut out) { - Ok(len) => { - let pts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0); - let _ = connector.send_mic(seq, pts, out[..len].to_vec()); - seq = seq.wrapping_add(1); - } - Err(e) => tracing::debug!(error = %e, "opus mic encode"), - } - } - } - audio_client.stop_stream().ok(); - Ok(()) -} diff --git a/clients/windows/src/gpu.rs b/clients/windows/src/gpu.rs index 15707a8b..d65e3991 100644 --- a/clients/windows/src/gpu.rs +++ b/clients/windows/src/gpu.rs @@ -1,104 +1,14 @@ -//! The single Direct3D 11 device shared by the video decoder (D3D11VA hardware decode) and the -//! presenter (the `SwapChainPanel` composition swapchain + the present draw). +//! DXGI adapter enumeration for the Settings "GPU" picker. //! -//! Zero-copy hardware decode requires FFmpeg to decode HEVC into `ID3D11Texture2D`s created by the -//! **same** device the presenter binds as shader resources and draws with — a texture from one -//! device can't be sampled by another. So the device is created once, here, and both subsystems -//! pull it from a process-global `OnceLock` (initialised on whichever thread asks first: the -//! 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, 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. -//! -//! **Thread-safety.** windows-rs COM interfaces are deliberately `!Send`/`!Sync` — thread-safety -//! is per-object, not universal. An `ID3D11Device` and its immediate context become free-threaded -//! once `ID3D11Multithread::SetMultithreadProtected(TRUE)` is set, which FFmpeg's D3D11VA backend -//! does inside `av_hwdevice_ctx_init` (it installs an `ID3D11Multithread`-based default lock when we -//! leave `AVD3D11VADeviceContext.lock` null). The decoder then uses FFmpeg's separate -//! `ID3D11VideoContext` for decode while the presenter uses the immediate context for draw; under -//! multithread protection D3D serialises the two internally, and decode/draw touch disjoint context -//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage. +//! Streaming (decode + present) runs in the spawned `punktfunk-session` binary; the shell only +//! needs the list of real (hardware) adapters to offer on a multi-GPU box (a hybrid laptop or an +//! eGPU). The picked adapter description is persisted (`crate::trust::Settings::adapter`) and read +//! by the session child at connect (`PUNKTFUNK_ADAPTER` remains the session binary's env override). -use anyhow::{anyhow, Result}; -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, - D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1, -}; -use windows::Win32::Graphics::Direct3D11::{ - D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, D3D11_CREATE_DEVICE_FLAG, - D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, -}; use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1}; -pub struct SharedDevice { - pub device: ID3D11Device, - pub context: ID3D11DeviceContext, - /// True when this is a real GPU (hardware) adapter — a precondition for D3D11VA decode. WARP - /// (the GPU-less dev box) creates fine for present but cannot hardware-decode HEVC, so the - /// decoder skips straight to the software path there. - pub hardware: bool, -} - -// Sound for our usage — see the module docs: the device + immediate context are free-threaded under -// the multithread protection FFmpeg installs, and decode (video context) / present (immediate -// context) never share mutable context state. -unsafe impl Send for SharedDevice {} -unsafe impl Sync for SharedDevice {} - -/// 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 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) -} - -/// 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 - } - } -} - -/// The adapter's human-readable description, for the logs. +/// The adapter's human-readable description. fn adapter_name(adapter: &IDXGIAdapter) -> String { unsafe { adapter @@ -112,7 +22,7 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String { } } -/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=` uses these indices). +/// Every DXGI adapter, in enumeration order. fn all_adapters() -> Vec { let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } { Ok(f) => f, @@ -144,136 +54,3 @@ pub fn adapter_names() -> Vec { .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 { - let needle = pref.to_lowercase(); - adapters - .iter() - .find(|a| adapter_name(a).to_lowercase().contains(&needle)) - .cloned() - }; - match &found { - 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; - } - } - - // The adapter driving the monitor our window sits on: DWM composes that monitor with it, so - // presenting from it is copy-free (a hybrid box's other adapter would pay a cross-adapter - // copy per frame). - let monitor = unsafe { - use windows::Win32::Graphics::Gdi::{MonitorFromWindow, MONITOR_DEFAULTTONULL}; - use windows::Win32::UI::WindowsAndMessaging::FindWindowW; - let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?; - MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL) - }; - if monitor.is_invalid() { - return None; - } - for adapter in &adapters { - let mut oi = 0u32; - while let Ok(output) = unsafe { adapter.EnumOutputs(oi) } { - oi += 1; - if let Ok(desc) = unsafe { output.GetDesc() } { - if desc.Monitor == monitor { - tracing::info!(adapter = %adapter_name(adapter), "using the window's monitor adapter"); - return Some(adapter.clone()); - } - } - } - } - None -} - -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(pref); - let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter { - Some(a) => [ - (Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true), - (Some(a), D3D_DRIVER_TYPE_UNKNOWN, false, true), - (None, D3D_DRIVER_TYPE_WARP, false, false), - ], - None => [ - (None, D3D_DRIVER_TYPE_HARDWARE, true, true), - (None, D3D_DRIVER_TYPE_HARDWARE, false, true), - (None, D3D_DRIVER_TYPE_WARP, false, false), - ], - }; - // The debug layer needs the SDK layers installed (Graphics Tools); when they're missing the - // creation fails, so each attempt retries without the flag rather than failing the ladder. - let debug = std::env::var("PUNKTFUNK_D3D_DEBUG").is_ok_and(|v| v == "1"); - for (adapter, driver, video, hardware) in attempts { - let mut flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; - if video { - flags |= D3D11_CREATE_DEVICE_VIDEO_SUPPORT; - } - let flag_sets: &[D3D11_CREATE_DEVICE_FLAG] = if debug { - &[flags | D3D11_CREATE_DEVICE_DEBUG, flags] - } else { - &[flags] - }; - for &flags in flag_sets { - let mut device = None; - let mut context = None; - let r = unsafe { - D3D11CreateDevice( - adapter, - driver, - None, - flags, - Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]), - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - }; - if r.is_ok() { - let (device, context) = (device.unwrap(), context.unwrap()); - // Make the device + immediate context free-threaded: the decoder (D3D11VA video - // context, pump thread) and the presenter (immediate context, render thread) both - // touch this device. FFmpeg also sets this during hwdevice init, but doing it up - // front keeps the cross-thread `Send`/`Sync` sound from the moment the device exists. - if let Ok(mt) = context.cast::() { - unsafe { - let _ = mt.SetMultithreadProtected(true); // returns the prior state; ignore - } - } - tracing::info!( - adapter = %adapter.map(adapter_name).unwrap_or_else(|| if hardware { - "default".into() - } else { - "WARP (software)".into() - }), - video, - debug = (flags & D3D11_CREATE_DEVICE_DEBUG).0 != 0, - "shared D3D11 device created" - ); - return Ok(SharedDevice { - device, - context, - hardware, - }); - } - } - } - Err(anyhow!( - "D3D11CreateDevice failed for both hardware and WARP" - )) -} diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs deleted file mode 100644 index b715bbd6..00000000 --- a/clients/windows/src/input.rs +++ /dev/null @@ -1,742 +0,0 @@ -//! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI -//! window is focused and the pointer is captured. -//! -//! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard -//! *accelerators* and pointer button-state), which is insufficient for a game stream. So this -//! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the -//! stream page mounts and removed when it unmounts. -//! -//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks -//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and -//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the -//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with -//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so -//! it never reaches a screen edge. This is why the old absolute path froze: swallowing -//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate -//! snapped to one point. Keys carry the **US-positional VK** for the pressed physical key (the -//! punktfunk wire contract shared by every first-party client — see [`scan_to_positional_vk`]): -//! the hook's layout-resolved `vkCode` must NOT go on the wire, or a non-US pair re-maps -//! positions through two layouts (German: y↔z swapped, ü lands on ö). -//! -//! **Capture state machine** (parity with the GTK/Swift clients): capture engages at stream -//! start, **Ctrl+Alt+Shift+Q** releases it (handing the cursor back to the local desktop), and a -//! **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. **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. -//! **Ctrl+Alt+Shift+S** toggles the stats overlay live and **F11** toggles fullscreen — both are -//! client-local shortcuts (consumed, never forwarded), matching the GTK client's stream key set. - -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::Mode; -use punktfunk_core::input::{InputEvent, InputKind}; -use std::collections::HashSet; -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; -use std::sync::{Arc, Mutex}; -use windows::core::BOOL; -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_D, VK_F11, VK_Q, VK_S}; -use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; -use windows::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, ClipCursor, EnumChildWindows, GetClientRect, GetForegroundWindow, SetCursor, - SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, - KBDLLHOOKSTRUCT, LLKHF_EXTENDED, LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, - WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, - WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SYSKEYUP, - WM_XBUTTONDOWN, WM_XBUTTONUP, -}; - -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). - captured: bool, - /// Forward system shortcuts (Alt+Tab, Win, …) to the host; off = they act locally. - inhibit_shortcuts: bool, - /// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real - /// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition. - locked: bool, - /// Lock geometry, captured when the lock engages: the confinement rect (screen coordinates, - /// also the click-to-capture hit test), its centre (the cursor is warped here after every - /// move), and the screen→host scale (the Contain-fit display scale's inverse). Stable while - /// locked — the window can't be moved or resized with the cursor confined inside it. - clip: RECT, - center_x: i32, - center_y: i32, - scale: f32, - /// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away. - acc_x: f32, - acc_y: f32, - /// Modifier state, tracked from the hook's own event stream (see `kbd_proc`). - ctrl: bool, - alt: bool, - shift: bool, - held_keys: HashSet, - held_buttons: HashSet, -} - -// `State` carries no `!Send` handle (hwnd is an `isize`), so the static is sound. The hook procs -// run on the same UI thread that installs/removes the hooks, so the lock is uncontended. -static STATE: Mutex> = Mutex::new(None); -static KBD_HOOK: AtomicIsize = AtomicIsize::new(0); -static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0); -/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll). -static CAPTURED: AtomicBool = AtomicBool::new(false); -/// Live stats-overlay visibility. Seeded from `Settings::show_stats` at `install`, then toggled by -/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll -/// reads it lock-free to drive the overlay. -static HUD_VISIBLE: AtomicBool = AtomicBool::new(false); -/// Whether the pointer lock currently wants the OS cursor hidden. Read lock-free by -/// [`cursor_subclass_proc`] (which runs on the UI thread inside `WM_SETCURSOR`) so it can override -/// WinUI's per-move arrow re-assertion — a one-shot `ShowCursor(false)` alone loses that race -/// because the content island re-sets the arrow every time the pointer moves. -static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false); -/// Our `SetWindowSubclass` id on the WinUI window + its content-island children (any stable value; -/// scopes the subclass so install/remove target exactly our proc). -const CURSOR_SUBCLASS_ID: usize = 0x7066_6375; // 'pfcu' - -/// Whether stream input is currently captured (drives the HUD's release/capture hint). -pub fn is_captured() -> bool { - CAPTURED.load(Ordering::Relaxed) -} - -/// Whether the stats overlay should be shown: the Settings default at stream start, then whatever -/// Ctrl+Alt+Shift+S last set for the session. Read by the HUD poll thread. -pub fn hud_visible() -> bool { - HUD_VISIBLE.load(Ordering::Relaxed) -} - -/// Set the capture intent and engage/release the pointer lock to match. -fn set_captured(st: &mut State, on: bool) { - st.captured = on; - CAPTURED.store(on, Ordering::Relaxed); - set_locked(st, on); - if !on { - flush_held(st); // release held keys/buttons so nothing sticks on the host - } -} - -/// 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. -/// `show_stats` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live. -/// `stop` is the session's stop flag, tripped by the disconnect shortcut. -pub fn install( - connector: Arc, - mode: Mode, - inhibit_shortcuts: bool, - show_stats: bool, - stop: Arc, -) { - HUD_VISIBLE.store(show_stats, Ordering::Relaxed); - let hwnd = unsafe { GetForegroundWindow() }; - let mut st = State { - connector, - mode, - stop, - hwnd: hwnd.0 as isize, - captured: false, - inhibit_shortcuts, - locked: false, - clip: RECT::default(), - center_x: 0, - center_y: 0, - scale: 1.0, - acc_x: 0.0, - acc_y: 0.0, - ctrl: false, - alt: false, - shift: false, - held_keys: HashSet::new(), - held_buttons: HashSet::new(), - }; - // Capture immediately (the window is foreground at mount, like Moonlight grabbing on stream - // start). - set_captured(&mut st, true); - *STATE.lock().unwrap() = Some(st); - unsafe { - let hinst = GetModuleHandleW(None).ok(); - if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) { - KBD_HOOK.store(h.0 as isize, Ordering::SeqCst); - } - if let Ok(h) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), hinst.map(Into::into), 0) { - MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst); - } - } - tracing::info!( - inhibit_shortcuts, - "stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)" - ); -} - -/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing -/// sticks down on the host). -pub fn uninstall() { - unsafe { - let k = KBD_HOOK.swap(0, Ordering::SeqCst); - if k != 0 { - let _ = UnhookWindowsHookEx(HHOOK(k as *mut _)); - } - let m = MOUSE_HOOK.swap(0, Ordering::SeqCst); - if m != 0 { - let _ = UnhookWindowsHookEx(HHOOK(m as *mut _)); - } - } - if let Some(mut st) = STATE.lock().unwrap().take() { - // Hand the cursor back + flush held state. - set_captured(&mut st, false); - // Drop the WM_SETCURSOR subclass so the long-lived app window (reused for the host list - // once the stream ends) is left pristine — set_captured already cleared CURSOR_HIDDEN. - remove_cursor_subclass(HWND(st.hwnd as *mut _)); - // Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window - // so the GUI (the host list) is never left borderless-fullscreen after the stream ends. - exit_fullscreen(HWND(st.hwnd as *mut _)); - } -} - -/// Release every held key/button on the host, so nothing sticks down when capture is dropped -/// (toggled off) or the session ends. -fn flush_held(st: &mut State) { - let c = st.connector.clone(); - for vk in st.held_keys.drain() { - send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0); - } - for b in st.held_buttons.drain() { - send(&c, InputKind::MouseButtonUp, b, 0, 0, 0); - } -} - -/// Subclass proc on the WinUI window + its content-island children: while the pointer lock wants -/// the cursor hidden ([`CURSOR_HIDDEN`]), answer `WM_SETCURSOR` ourselves with `SetCursor(None)` -/// and return TRUE — halting WinUI's default handling before it re-asserts the arrow. This is what -/// actually keeps the cursor hidden while captured; the sibling `ShowCursor(false)` cannot, because -/// WinUI re-sets the arrow on every pointer move (the content island answers `WM_SETCURSOR` itself, -/// which a low-level mouse hook never sees). When not hidden, we defer to the chain untouched. -unsafe extern "system" fn cursor_subclass_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, - _id: usize, - _ref: usize, -) -> LRESULT { - if msg == WM_SETCURSOR && CURSOR_HIDDEN.load(Ordering::Relaxed) { - unsafe { - let _ = SetCursor(None); - } - return LRESULT(1); // handled — suppress the framework's arrow re-assertion - } - unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } -} - -unsafe extern "system" fn subclass_install_cb(child: HWND, _l: LPARAM) -> BOOL { - unsafe { - let _ = SetWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0); - } - BOOL(1) // keep enumerating -} - -unsafe extern "system" fn subclass_remove_cb(child: HWND, _l: LPARAM) -> BOOL { - unsafe { - let _ = RemoveWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID); - } - BOOL(1) -} - -/// Install [`cursor_subclass_proc`] on the top-level WinUI window and every descendant — the video -/// is a composition SwapChainPanel, so the pointer actually sits over WinUI's internal content- -/// island child window, which is the window that receives `WM_SETCURSOR`. `EnumChildWindows` -/// recurses into all descendants, so one pass covers it. Idempotent (re-installing the same -/// id+proc just refreshes it), so it's safe to call on every lock engage. -fn install_cursor_subclass(top: HWND) { - unsafe { - let _ = SetWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0); - let _ = EnumChildWindows(Some(top), Some(subclass_install_cb), LPARAM(0)); - } -} - -/// Remove our subclass from the top-level window and every descendant. Called on teardown so the -/// long-lived app window (reused for the host list after the stream ends) is left pristine. -fn remove_cursor_subclass(top: HWND) { - unsafe { - let _ = RemoveWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID); - let _ = EnumChildWindows(Some(top), Some(subclass_remove_cb), LPARAM(0)); - } -} - -/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off. -/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition). -/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`. -fn set_locked(st: &mut State, on: bool) { - if on == st.locked { - return; - } - let hwnd = HWND(st.hwnd as *mut _); - unsafe { - if on { - let mut rc = RECT::default(); - if GetClientRect(hwnd, &mut rc).is_ok() { - let mut tl = POINT { - x: rc.left, - y: rc.top, - }; - let mut br = POINT { - x: rc.right, - y: rc.bottom, - }; - let _ = ClientToScreen(hwnd, &mut tl); - let _ = ClientToScreen(hwnd, &mut br); - st.clip = RECT { - left: tl.x, - top: tl.y, - right: br.x, - bottom: br.y, - }; - let _ = ClipCursor(Some(&st.clip as *const RECT)); - st.center_x = (tl.x + br.x) / 2; - st.center_y = (tl.y + br.y) / 2; - // Screen px → host px: the Contain-fit display scale's inverse, so the host - // cursor tracks the physical mouse 1:1 on screen at any window size. - let (ww, wh) = ((br.x - tl.x).max(1) as f32, (br.y - tl.y).max(1) as f32); - let (vw, vh) = (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32); - st.scale = (ww / vw).min(wh / vh).max(0.01); - let _ = SetCursorPos(st.center_x, st.center_y); - } - // Hide the OS cursor. ShowCursor(false) is the coarse gate; the subclass is what - // actually holds it hidden against WinUI's per-move arrow re-assertion — see - // cursor_subclass_proc / install_cursor_subclass. - let _ = ShowCursor(false); - CURSOR_HIDDEN.store(true, Ordering::Relaxed); - install_cursor_subclass(hwnd); - st.acc_x = 0.0; - st.acc_y = 0.0; - } else { - CURSOR_HIDDEN.store(false, Ordering::Relaxed); - let _ = ClipCursor(None); - let _ = ShowCursor(true); - } - } - st.locked = on; -} - -/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it. -/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit -/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?" -/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream -/// page's unmount), but a Mutex keeps the static sound + `Sync`. -static SAVED_PLACEMENT: Mutex> = - Mutex::new(None); - -/// Whether our top-level window is currently borderless-fullscreen. Entering strips -/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`]. -fn is_fullscreen(hwnd: HWND) -> bool { - use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW, - }; - let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; - unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 } -} - -/// Enter borderless fullscreen: remember the window placement, drop the frame -/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns -/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the -/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize. -fn enter_fullscreen(hwnd: HWND) { - use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY, - }; - use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE, - SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW, - }; - let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; - unsafe { - let style = GetWindowLongPtrW(hwnd, GWL_STYLE); - let mut wp = WINDOWPLACEMENT { - length: std::mem::size_of::() as u32, - ..Default::default() - }; - let mut mi = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); - if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() { - *SAVED_PLACEMENT.lock().unwrap() = Some(wp); - SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped); - let r = mi.rcMonitor; - let _ = SetWindowPos( - hwnd, - None, - r.left, - r.top, - r.right - r.left, - r.bottom - r.top, - SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, - ); - } - } -} - -/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we -/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop. -fn exit_fullscreen(hwnd: HWND) { - use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE, - SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, - WS_OVERLAPPEDWINDOW, - }; - let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else { - return; // never went fullscreen — nothing to restore - }; - let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; - unsafe { - let style = GetWindowLongPtrW(hwnd, GWL_STYLE); - SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped); - let _ = SetWindowPlacement(hwnd, &wp); - let _ = SetWindowPos( - hwnd, - None, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, - ); - } -} - -/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into -/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too. -fn toggle_fullscreen(hwnd: isize) { - let hwnd = HWND(hwnd as *mut _); - if is_fullscreen(hwnd) { - exit_fullscreen(hwnd); - } else { - enter_fullscreen(hwnd); - } -} - -fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { - let _ = c.send_input(&InputEvent { - kind, - _pad: [0; 3], - code, - x, - y, - flags, - }); -} - -/// System shortcuts that act on the LOCAL desktop when "capture system shortcuts" is off: -/// the Win keys, Alt+Tab, and Alt/Ctrl+Esc. -fn is_system_shortcut(st: &State, vk: u16) -> bool { - match vk { - 0x5B | 0x5C => true, // L/R Win - 0x09 => st.alt, // Alt+Tab - 0x1B => st.alt || st.ctrl, // Alt+Esc / Ctrl+Esc - _ => false, - } -} - -unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { - if code == HC_ACTION as i32 { - let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) }; - let msg = wparam.0 as u32; - let up = msg == WM_KEYUP || msg == WM_SYSKEYUP; - let vk = kb.vkCode as u16; - let mut guard = STATE.lock().unwrap(); - if let Some(st) = guard.as_mut() { - // Track modifier state from the hook's own event stream — reliable even while we - // swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL - // hook, which is why the shortcut never fired). Handles the generic + L/R vk codes. - match kb.vkCode { - 0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL - 0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt) - 0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT - _ => {} - } - let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd; - if foreground { - // Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded). - if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift { - let on = !st.captured; - set_captured(st, on); - 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); - // Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session - // down immediately instead of holding the keep-alive linger for a reconnect. - st.connector.disconnect_quit(); - st.stop.store(true, Ordering::SeqCst); - tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)"); - return LRESULT(1); - } - // Toggle the stats overlay: Ctrl+Alt+Shift+S (consumed locally). Seeded from - // Settings at install; this live toggle overrides it for the session — parity - // with the GTK client, where `s` flips the OSD without leaving the stream. - if !up && vk == VK_S.0 && st.ctrl && st.alt && st.shift { - let on = !HUD_VISIBLE.load(Ordering::Relaxed); - HUD_VISIBLE.store(on, Ordering::Relaxed); - tracing::info!(hud = on, "stats overlay toggled (Ctrl+Alt+Shift+S)"); - return LRESULT(1); - } - // Toggle fullscreen: F11 (consumed locally, no modifiers — a client shortcut, - // never a wire key). Works captured or released. The window resize changes the - // client rect, so re-lock to recompute the pointer confinement + recentre. - if !up && vk == VK_F11.0 { - toggle_fullscreen(st.hwnd); - if st.locked { - set_locked(st, false); - set_locked(st, true); - } - tracing::info!("fullscreen toggled (F11)"); - return LRESULT(1); - } - if st.captured { - // With shortcut capture off, hand Alt+Tab & co. to the local desktop — - // neither forwarded nor swallowed. - if !st.inhibit_shortcuts && is_system_shortcut(st, vk) { - return unsafe { CallNextHookEx(None, code, wparam, lparam) }; - } - // Wire key: the US-positional VK for this physical key (module docs), derived - // from the scancode. `vkCode` is layout-semantic and only passes through for - // keys the table doesn't cover — extended keys and everything outside the - // typing area, where positional == semantic (plus injected events with - // scanCode 0 from remapping tools, best-effort). - let ext = (kb.flags.0 & LLKHF_EXTENDED.0) != 0; - let v = if ext { - vk as u8 - } else { - scan_to_positional_vk(kb.scanCode as u16).unwrap_or(vk as u8) - }; - if up { - if st.held_keys.remove(&v) { - send(&st.connector, InputKind::KeyUp, v as u32, 0, 0, 0); - } - } else { - st.held_keys.insert(v); - send(&st.connector, InputKind::KeyDown, v as u32, 0, 0, 0); - } - return LRESULT(1); // swallow so it reaches the host, not the local OS - } - } - } - } - unsafe { CallNextHookEx(None, code, wparam, lparam) } -} - -/// Whether a screen point lies inside the window's CURRENT client area (the click-to-capture -/// hit test — computed fresh per click, since the window can move/resize while released). -fn in_client_area(hwnd: isize, pt: POINT) -> bool { - let hwnd = HWND(hwnd as *mut _); - let mut rc = RECT::default(); - if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() { - return false; - } - let mut tl = POINT { - x: rc.left, - y: rc.top, - }; - let mut br = POINT { - x: rc.right, - y: rc.bottom, - }; - unsafe { - let _ = ClientToScreen(hwnd, &mut tl); - let _ = ClientToScreen(hwnd, &mut br); - } - pt.x >= tl.x && pt.x < br.x && pt.y >= tl.y && pt.y < br.y -} - -unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { - if code == HC_ACTION as i32 { - let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) }; - let msg = wparam.0 as u32; - let injected = (ms.flags & LLMHF_INJECTED) != 0; - let mut guard = STATE.lock().unwrap(); - if let Some(st) = guard.as_mut() { - let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd; - let want_lock = st.captured && foreground; - if want_lock != st.locked { - set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground) - } - // Click-to-capture: after a Ctrl+Alt+Shift+Q release, a primary click on the stream - // re-engages capture. The click is consumed — it starts the grab, it isn't gameplay. - if !st.captured - && foreground - && msg == WM_LBUTTONDOWN - && !injected - && in_client_area(st.hwnd, ms.pt) - { - set_captured(st, true); - tracing::info!("capture re-engaged (click on stream)"); - return LRESULT(1); - } - if st.locked { - // Skip the synthetic move our own SetCursorPos recentre generates. - if injected { - return unsafe { CallNextHookEx(None, code, wparam, lparam) }; - } - let c = st.connector.clone(); - match msg { - WM_MOUSEMOVE => { - let dx = (ms.pt.x - st.center_x) as f32; - let dy = (ms.pt.y - st.center_y) as f32; - if dx != 0.0 || dy != 0.0 { - st.acc_x += dx / st.scale; - st.acc_y += dy / st.scale; - let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32); - st.acc_x -= hx as f32; - st.acc_y -= hy as f32; - if hx != 0 || hy != 0 { - send(&c, InputKind::MouseMove, 0, hx, hy, 0); - } - } - let _ = unsafe { SetCursorPos(st.center_x, st.center_y) }; - } - WM_LBUTTONDOWN => button(st, 1, true), - WM_LBUTTONUP => button(st, 1, false), - WM_RBUTTONDOWN => button(st, 3, true), - WM_RBUTTONUP => button(st, 3, false), - WM_MBUTTONDOWN => button(st, 2, true), - WM_MBUTTONUP => button(st, 2, false), - WM_XBUTTONDOWN => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), true), - WM_XBUTTONUP => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), false), - WM_MOUSEWHEEL => send( - &c, - InputKind::MouseScroll, - 0, - (ms.mouseData >> 16) as i16 as i32, - 0, - 0, - ), - WM_MOUSEHWHEEL => send( - &c, - InputKind::MouseScroll, - 1, - (ms.mouseData >> 16) as i16 as i32, - 0, - 0, - ), - _ => {} - } - return LRESULT(1); // swallow inside the locked window - } - } - } - unsafe { CallNextHookEx(None, code, wparam, lparam) } -} - -fn button(st: &mut State, id: u32, down: bool) { - let c = st.connector.clone(); - if down { - st.held_buttons.insert(id); - send(&c, InputKind::MouseButtonDown, id, 0, 0, 0); - } else if st.held_buttons.remove(&id) { - send(&c, InputKind::MouseButtonUp, id, 0, 0, 0); - } -} - -/// Set-1 make scancode → US-positional VK for the layout-**variant** typing area (letters, digit -/// row, OEM punctuation, the ISO 102nd key) — the exact inverse of the host injector's positional -/// table and the Windows analogue of the Linux client's `evdev_to_vk`. Keys not listed (F-row, -/// nav cluster, numpad, modifiers — plus every E0-extended key, which the caller filters out) -/// have layout-invariant VKs, so the hook's `vkCode` is already correct for them. -fn scan_to_positional_vk(scan: u16) -> Option { - Some(match scan { - 0x02..=0x0A => (scan - 0x02) as u8 + 0x31, // 1..9 - 0x0B => 0x30, // 0 - 0x0C => 0xBD, // -_ VK_OEM_MINUS (DE: ß) - 0x0D => 0xBB, // =+ VK_OEM_PLUS - 0x10 => 0x51, // Q - 0x11 => 0x57, // W - 0x12 => 0x45, // E - 0x13 => 0x52, // R - 0x14 => 0x54, // T - 0x15 => 0x59, // Y position (QWERTZ: the Z key) - 0x16 => 0x55, // U - 0x17 => 0x49, // I - 0x18 => 0x4F, // O - 0x19 => 0x50, // P - 0x1A => 0xDB, // [{ VK_OEM_4 (DE: ü) - 0x1B => 0xDD, // ]} VK_OEM_6 - 0x1E => 0x41, // A - 0x1F => 0x53, // S - 0x20 => 0x44, // D - 0x21 => 0x46, // F - 0x22 => 0x47, // G - 0x23 => 0x48, // H - 0x24 => 0x4A, // J - 0x25 => 0x4B, // K - 0x26 => 0x4C, // L - 0x27 => 0xBA, // ;: VK_OEM_1 (DE: ö) - 0x28 => 0xDE, // '" VK_OEM_7 (DE: ä) - 0x29 => 0xC0, // `~ VK_OEM_3 (DE: ^) - 0x2B => 0xDC, // \| VK_OEM_5 - 0x2C => 0x5A, // Z position (QWERTZ: the Y key) - 0x2D => 0x58, // X - 0x2E => 0x43, // C - 0x2F => 0x56, // V - 0x30 => 0x42, // B - 0x31 => 0x4E, // N - 0x32 => 0x4D, // M - 0x33 => 0xBC, // ,< VK_OEM_COMMA - 0x34 => 0xBE, // .> VK_OEM_PERIOD - 0x35 => 0xBF, // /? VK_OEM_2 - 0x56 => 0xE2, // <>| VK_OEM_102 (ISO) - _ => return None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The German-scramble regression pins: the physical keys a QWERTZ board labels Z/Y/ö/ü must - /// leave this client as their US-position VKs, regardless of the local layout's vkCode. - #[test] - fn positional_pins_for_the_qwertz_scramble() { - assert_eq!(scan_to_positional_vk(0x15), Some(0x59)); // QWERTZ Z key → VK_Y (US position) - assert_eq!(scan_to_positional_vk(0x2C), Some(0x5A)); // QWERTZ Y key → VK_Z (US position) - assert_eq!(scan_to_positional_vk(0x27), Some(0xBA)); // ö key → VK_OEM_1 (US ;: position) - assert_eq!(scan_to_positional_vk(0x1A), Some(0xDB)); // ü key → VK_OEM_4 (US [{ position) - assert_eq!(scan_to_positional_vk(0x28), Some(0xDE)); // ä key → VK_OEM_7 (US '" position) - assert_eq!(scan_to_positional_vk(0x0C), Some(0xBD)); // ß key → VK_OEM_MINUS (US -_ position) - } - - /// Keys outside the layout-variant typing area stay un-mapped (vkCode passes through). - #[test] - fn invariant_keys_fall_through() { - for scan in [ - 0x01u16, 0x0E, 0x0F, 0x1C, 0x1D, 0x2A, 0x36, 0x38, 0x39, 0x3B, 0x45, 0x57, - ] { - assert_eq!(scan_to_positional_vk(scan), None, "scan 0x{scan:02X}"); - } - } - - /// Exactly the 48 typing-area keys are covered (10 digits + 26 letters + 12 OEM), and every - /// mapping is unique — two physical keys must never collapse onto one wire VK. - #[test] - fn table_covers_the_typing_area_bijectively() { - let mapped: Vec<(u16, u8)> = (0u16..=0xFF) - .filter_map(|sc| scan_to_positional_vk(sc).map(|vk| (sc, vk))) - .collect(); - assert_eq!(mapped.len(), 48); - let mut vks: Vec = mapped.iter().map(|&(_, vk)| vk).collect(); - vks.sort_unstable(); - vks.dedup(); - assert_eq!(vks.len(), 48, "duplicate wire VK in the positional table"); - } -} diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 0ee3f106..4e28917c 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -1,17 +1,16 @@ //! `punktfunk-client` — the native Windows punktfunk/1 client. //! -//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · FFmpeg -//! decode · WASAPI audio · SDL3 gamepads · a **WinUI 3** shell (windows-reactor) with the video -//! on a `SwapChainPanel` bound to a D3D11 composition swapchain. The trust surface mirrors the +//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · SDL3 +//! gamepads · a **WinUI 3** shell (windows-reactor). Streaming (decode + present + audio) runs in +//! the spawned `punktfunk-session` Vulkan binary; the shell owns host selection, trust and +//! pairing. The trust surface mirrors the //! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app -//! (host list, settings, pairing). `--headless` keeps a CLI connect path for tests/measurement. +//! (host list, settings, pairing). Streaming runs in the spawned `punktfunk-session` binary; +//! `--headless --speed-test` keeps a decode-less CLI measurement path. //! //! Usage: //! punktfunk-client (open the WinUI 3 window: host list, settings, pairing) //! punktfunk-client --discover (list punktfunk hosts on the LAN) -//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz] -//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr] -//! (no window; count frames + print stats) //! punktfunk-client --headless --speed-test --connect host[:port] //! (measure the path: probe burst → goodput / loss / recommended bitrate) @@ -23,29 +22,19 @@ #[cfg(windows)] mod app; #[cfg(windows)] -mod audio; -#[cfg(windows)] mod discovery; #[cfg(windows)] mod gamepad; #[cfg(windows)] mod gpu; #[cfg(windows)] -mod input; -#[cfg(windows)] -mod present; -#[cfg(windows)] -mod render; -#[cfg(windows)] -mod session; +mod probe; #[cfg(windows)] mod shell_window; #[cfg(windows)] mod spawn; #[cfg(windows)] mod trust; -#[cfg(windows)] -mod video; #[cfg(windows)] mod wol; @@ -124,13 +113,11 @@ fn set_app_user_model_id() { } } -/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the -/// Windows analogue of `punktfunk-probe`. +/// `--headless --speed-test --connect host[:port]`: measure the path over the real data plane and +/// print the outcome — the Windows analogue of `punktfunk-probe`. The former in-process +/// frame-count connect path went with the legacy builtin stream; real streaming is windowed-only. #[cfg(windows)] fn run_headless_cli(args: &[String], identity: (String, String)) { - use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; - use std::time::{Duration, Instant}; - let arg = |name: &str| -> Option { args.iter() .position(|a| a == name) @@ -154,7 +141,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { let fp = trust::KnownHosts::load() .find_by_addr(&host, port) .map(|k| k.fp_hex.clone()); - match session::run_speed_probe(&host, port, fp.as_deref(), identity) { + match probe::run_speed_probe(&host, port, fp.as_deref(), identity) { Ok(r) => { let mbps = f64::from(r.throughput_kbps) / 1000.0; let recommended = f64::from(r.throughput_kbps / 10 * 7) / 1000.0; @@ -171,144 +158,10 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { } return; } - let mode = arg("--mode") - .and_then(|m| { - let mut it = m.split(['x', 'X']); - Some(Mode { - width: it.next()?.parse().ok()?, - height: it.next()?.parse().ok()?, - refresh_hz: it.next()?.parse().ok()?, - }) - }) - .unwrap_or(Mode { - width: 1280, - height: 720, - refresh_hz: 60, - }); - let bitrate_kbps = arg("--bitrate") - .and_then(|b| b.parse::().ok()) - .map(|m| m * 1000) - .unwrap_or(0); - - let known = trust::KnownHosts::load(); - let mut pin = arg("--pin") - .and_then(|h| trust::parse_hex32(&h)) - .or_else(|| { - known - .find_by_addr(&host, port) - .and_then(|k| trust::parse_hex32(&k.fp_hex)) - }); - if let Some(code) = arg("--pair") { - let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into()); - match punktfunk_core::client::NativeClient::pair( - &host, - port, - (&identity.0, &identity.1), - code.trim(), - &name, - Duration::from_secs(90), - ) { - Ok(fp) => { - let mut k = trust::KnownHosts::load(); - k.upsert(trust::KnownHost { - name: host.clone(), - addr: host.clone(), - port, - fp_hex: trust::hex(&fp), - paired: true, - last_used: None, - mac: Vec::new(), - }); - let _ = k.save(); - tracing::info!(fp = %trust::hex(&fp), "paired"); - pin = Some(fp); - } - Err(e) => { - eprintln!("Pairing failed: {e:?}"); - std::process::exit(1); - } - } - } - - let decoder = arg("--decoder") - .map(|d| crate::video::DecoderPref::from_name(&d)) - .unwrap_or_default(); - - tracing::info!(%host, port, ?mode, tofu = pin.is_none(), ?decoder, "connecting (headless)"); - let handle = session::start(session::SessionParams { - host, - port, - mode, - compositor: CompositorPref::Auto, - gamepad: GamepadPref::Auto, - bitrate_kbps, - // Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings. - audio_channels: 2, - mic_enabled: flag("--mic"), - hdr_enabled: !flag("--no-hdr"), - decoder, - // `--codec h264|hevc|av1` sets the soft preference; default auto (host decides). - preferred_codec: match arg("--codec").as_deref() { - Some("h264") | Some("avc") => punktfunk_core::quic::CODEC_H264, - Some("hevc") | Some("h265") => punktfunk_core::quic::CODEC_HEVC, - Some("av1") => punktfunk_core::quic::CODEC_AV1, - _ => 0, - }, - pin, - identity, - // Headless CLI uses the normal (short) handshake budget; the long request-access wait is a - // GUI-only flow. - connect_timeout: Duration::from_secs(15), - }); - - let deadline = Instant::now() + Duration::from_secs(60); - let mut frames_seen = 0u64; - loop { - while let Ok(ev) = handle.events.try_recv() { - match ev { - session::SessionEvent::Connected { - mode, fingerprint, .. - } => tracing::info!(?mode, fp = %trust::hex(&fingerprint), "connected"), - // With per-AU 0xCF host timings the combined host+network stage splits into - // host (capture→sent on the host) + net; an old host emits none → combined only. - session::SessionEvent::Stats(s) if s.split => tracing::info!( - fps = format!("{:.0}", s.fps), - mbps = format!("{:.1}", s.mbps), - decode_p50_ms = format!("{:.2}", s.decode_ms), - hostnet_p50_ms = format!("{:.2}", s.hostnet_ms), - host_p50_ms = format!("{:.2}", s.host_ms), - net_p50_ms = format!("{:.2}", s.net_ms), - frames_seen, - "stats" - ), - session::SessionEvent::Stats(s) => tracing::info!( - fps = format!("{:.0}", s.fps), - mbps = format!("{:.1}", s.mbps), - decode_p50_ms = format!("{:.2}", s.decode_ms), - hostnet_p50_ms = format!("{:.2}", s.hostnet_ms), - frames_seen, - "stats" - ), - session::SessionEvent::Failed { msg, .. } => { - tracing::error!(%msg, "connect failed"); - return; - } - session::SessionEvent::Ended(err) => { - tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended"); - return; - } - } - } - while handle.frames.try_recv().is_ok() { - frames_seen += 1; - } - if Instant::now() > deadline { - tracing::info!(frames_seen, "harness deadline — stopping"); - handle.stop.store(true, std::sync::atomic::Ordering::SeqCst); - return; - } - std::thread::sleep(Duration::from_millis(2)); - } + // Only --speed-test remains headless: real streaming runs in the windowed app's spawned + // punktfunk-session binary, which the deleted in-process frame-count path was replaced by. + eprintln!("--headless supports only --speed-test now \u{2014} run the windowed app to stream"); + std::process::exit(2); } /// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit. diff --git a/clients/windows/src/present.rs b/clients/windows/src/present.rs deleted file mode 100644 index 80dc8b04..00000000 --- a/clients/windows/src/present.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`. It draws a decoded frame Contain-fit into a -//! **composition** flip-model swapchain, which the reactor stream page binds to the panel via -//! `SwapChainPanelHandle::set_swap_chain`. After that one UI-thread bind, the presenter lives on -//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by) -//! the XAML thread. -//! -//! Two frame sources, ONE Y′CbCr→RGB shader whose conversion rows arrive per frame in a constant -//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour -//! math for both sources, and the stream's signaled matrix/range is honored, not assumed): -//! -//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010 -//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both -//! planes; in D3D11 a planar slice is a single subresource** (unlike D3D12) — into our -//! sampleable texture, which per-plane SRVs (R8/R8G8, R16/R16G16) expose to the shaders. The -//! source box is mandatory: the decode array is coded-size (e.g. 1920×1088), the target -//! display-size (1920×1080), and D3D11 silently drops size-mismatched full-resource copies. -//! * **CPU upload** — [`crate::video::CpuFrame`] carries NV12/P010 planes from the software -//! decoder; they upload into two dynamic plane textures feeding the same SRV slots/shaders. -//! -//! **Pacing**: the swapchain is created with `DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT` -//! and `SetMaximumFrameLatency(1)` (flagless fallback for odd drivers). The render thread waits -//! on the latency waitable before drawing, so at most one present is ever queued (minimum compose -//! latency) and a stream faster than the display drops frames *before* any GPU work. Every -//! `ResizeBuffers` must re-pass the creation flags — that's `swap_flags`. -//! -//! **HiDPI**: buffers are sized in physical pixels and `IDXGISwapChain2::SetMatrixTransform` -//! (scale 96/DPI) maps them to the panel's DIP coordinate space — without it XAML samples a -//! DIP-sized buffer up and the video is blurry at 125/150 % scaling. -//! -//! **HDR10**: when a frame is BT.2020 PQ the swapchain flips to `R10G10B10A2` + -//! `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` (+ HDR10 metadata) via `ResizeBuffers`/ -//! `SetColorSpace1`; the shader output is already PQ-encoded so the compositor maps PQ→display. SDR -//! stays 8-bit B8G8R8A8. -//! -//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the -//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`. - -use crate::video::{CpuFrame, DecodedFrame, GpuFrame}; -use anyhow::{anyhow, Context, Result}; -use windows::core::{Interface, PCSTR}; -use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; -use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3}; -use windows::Win32::Graphics::Direct3D::{ - ID3DBlob, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2D, -}; -use windows::Win32::Graphics::Direct3D11::*; -use windows::Win32::Graphics::Dxgi::Common::*; -use windows::Win32::Graphics::Dxgi::*; -use windows::Win32::System::Threading::WaitForSingleObject; - -// One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination: -// tex0 is the luma plane, tex1 the chroma plane, and the Y′CbCr→RGB conversion arrives as three -// constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` — -// bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader -// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old -// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to -// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded -// R′G′B′ passed through as-is to the HDR10 swapchain, exactly as before. -const SHADER_HLSL: &str = r#" -struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; -VSOut vs_main(uint vid : SV_VertexID) { - float2 uv = float2((vid << 1) & 2, vid & 2); - VSOut o; - o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); - o.uv = uv; - return o; -} -Texture2D tex0 : register(t0); -Texture2D tex1 : register(t1); -SamplerState smp : register(s0); -cbuffer Csc : register(b0) { - float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w - float4 r1; - float4 r2; -}; - -float4 ps_yuv(VSOut i) : SV_Target { - // 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and - // what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER - // siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma - // texels to re-align (the same correction the Apple client applies). Self-disables when the - // plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct). - float lw, lh, cw, ch; - tex0.GetDimensions(lw, lh); - tex1.GetDimensions(cw, ch); - float2 cuv = i.uv; - if (cw < lw) { cuv.x += 0.25 / cw; } - float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg); - float3 rgb = float3(dot(r0.xyz, yuv) + r0.w, - dot(r1.xyz, yuv) + r1.w, - dot(r2.xyz, yuv) + r2.w); - return float4(saturate(rgb), 1.0); -} -"#; - -/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane -/// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds -/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived. -struct Bound { - y: ID3D11ShaderResourceView, - c: ID3D11ShaderResourceView, -} - -pub struct Presenter { - device: ID3D11Device, - context: ID3D11DeviceContext, - vs: ID3D11VertexShader, - ps_yuv: ID3D11PixelShader, - /// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten - /// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init). - csc_buf: ID3D11Buffer, - sampler: ID3D11SamplerState, - swap: IDXGISwapChain1, - /// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails. - swap_flags: u32, - /// The frame-latency waitable (owned; closed in `Drop`), `None` on the flagless fallback. - waitable: Option, - rtv: Option, - /// GPU path: sampleable copy target for the decoded slice — `(tex, w, h, ten_bit)`, recreated - /// when the decoded size/bit depth changes. Format must equal the decode array's (NV12/P010). - sample_tex: Option<(ID3D11Texture2D, u32, u32, bool)>, - /// The last GPU frame, held until the NEXT bind so its decode surface stays out of the reuse - /// pool at least until this frame's copy has been queued ahead of any later decoder write. - gpu_frame: Option, - /// CPU path: dynamic luma + chroma plane textures + their SRVs — `(y, uv, y_srv, uv_srv, w, h, - /// ten_bit)`, recreated when the decoded size/bit depth changes. - #[allow(clippy::type_complexity)] - plane_tex: Option<( - ID3D11Texture2D, - ID3D11Texture2D, - ID3D11ShaderResourceView, - ID3D11ShaderResourceView, - u32, - u32, - bool, - )>, - bound: Option, - /// Source frame dimensions, for the Contain-fit letterbox. - src_w: u32, - src_h: u32, - /// Panel (swapchain) size in physical pixels + the window DPI, updated on resize. - panel_w: u32, - panel_h: u32, - dpi: u32, - /// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode. - hdr: bool, - /// The source's static HDR mastering metadata received over the protocol (`0xCE`), applied via - /// `SetHDRMetaData` so the display tone-maps from the real grade instead of a generic 1000-nit - /// guess. `None` until the first update arrives (then the generic baseline is used). - hdr_meta: Option, -} - -/// Latest source HDR mastering metadata, written by the session pump (`session.rs`, the sole -/// `next_hdr_meta` consumer) and read by the render thread before each present — decoupled so the -/// presenter doesn't need the connector. One session at a time on the client, so a single slot. -pub static LATEST_HDR_META: std::sync::Mutex> = - std::sync::Mutex::new(None); - -impl Presenter { - /// Create the presenter on the process-wide shared D3D11 device (the one the decoder uses), plus - /// the composition swapchain + shaders, sized to the panel in physical pixels at `dpi`. - pub fn new(width: u32, height: u32, dpi: u32) -> Result { - let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?; - let device = shared.device.clone(); - let context = shared.context.clone(); - let (vs, ps_yuv, sampler) = build_pipeline(&device)?; - // The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind. - let csc_desc = D3D11_BUFFER_DESC { - ByteWidth: 48, - Usage: D3D11_USAGE_DYNAMIC, - BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, - ..Default::default() - }; - let csc_buf = unsafe { - let mut b = None; - device - .CreateBuffer(&csc_desc, None, Some(&mut b)) - .context("CreateBuffer (CSC rows)")?; - b.ok_or_else(|| anyhow!("null CSC constant buffer"))? - }; - let (swap, swap_flags) = - create_composition_swapchain(&device, width.max(1), height.max(1))?; - // ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn - // when the compositor is ready to take it — the newest-wins drain happens after the wait. - let waitable = (swap_flags & DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32 - != 0) - .then(|| unsafe { - let sc2: IDXGISwapChain2 = swap.cast().ok()?; - sc2.SetMaximumFrameLatency(1).ok()?; - let h = sc2.GetFrameLatencyWaitableObject(); - (!h.is_invalid()).then_some(h) - }) - .flatten(); - let p = Presenter { - device, - context, - vs, - ps_yuv, - csc_buf, - sampler, - swap, - swap_flags, - waitable, - rtv: None, - sample_tex: None, - gpu_frame: None, - plane_tex: None, - bound: None, - src_w: 1, - src_h: 1, - panel_w: width.max(1), - panel_h: height.max(1), - dpi: dpi.max(96), - hdr: false, - hdr_meta: None, - }; - p.apply_dpi_matrix(); - Ok(p) - } - - /// Block until the swapchain can take another present (≤ `timeout_ms`). True when a present - /// slot is free; also true on the flagless fallback (no throttle available, just present). - pub fn wait_present_slot(&self, timeout_ms: u32) -> bool { - match self.waitable { - Some(h) => unsafe { WaitForSingleObject(h, timeout_ms) == WAIT_OBJECT_0 }, - None => true, - } - } - - /// Update the source HDR mastering metadata (from the `0xCE` plane). Stored for the next HDR - /// swapchain switch, and applied immediately if already presenting HDR. A no-op when unchanged - /// (so it's cheap to call every frame from the render loop). - pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) { - if self.hdr_meta == Some(meta) { - return; - } - self.hdr_meta = Some(meta); - if self.hdr { - unsafe { self.apply_hdr_metadata() }; - } - } - - /// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`. - pub fn swap_chain(&self) -> &IDXGISwapChain1 { - &self.swap - } - - /// Resize the back buffers to the panel's new size in physical pixels at `dpi` (drops the - /// stale RTV, re-applies the DIP↔pixel matrix). - pub fn resize(&mut self, width: u32, height: u32, dpi: u32) { - let dpi = dpi.max(96); - if width == 0 - || height == 0 - || (width == self.panel_w && height == self.panel_h && dpi == self.dpi) - { - return; - } - self.rtv = None; // release all back-buffer refs before ResizeBuffers - unsafe { - if let Err(e) = self.swap.ResizeBuffers( - 0, - width, - height, - DXGI_FORMAT_UNKNOWN, - DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32), - ) { - tracing::warn!(error = %e, "ResizeBuffers failed"); - return; - } - } - self.panel_w = width; - self.panel_h = height; - self.dpi = dpi; - self.apply_dpi_matrix(); - } - - /// Map the pixel-sized buffers into the panel's DIP coordinate space (scale 96/DPI) — XAML - /// otherwise stretches whatever size the buffers are to the panel's DIP bounds (blurry). - fn apply_dpi_matrix(&self) { - let s = 96.0 / self.dpi as f32; - if let Ok(sc2) = self.swap.cast::() { - let m = DXGI_MATRIX_3X2_F { - _11: s, - _22: s, - ..Default::default() - }; - if let Err(e) = unsafe { sc2.SetMatrixTransform(&m) } { - tracing::warn!(error = %e, "SetMatrixTransform failed"); - } - } - } - - /// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, re-present the last - /// one (or black). Called from the render thread. Takes the frame by value: the GPU path - /// retains the decoder surface until the next bind. - pub fn present(&mut self, frame: Option) { - match frame { - Some(DecodedFrame::Cpu(c)) => { - if c.hdr != self.hdr { - self.set_hdr(c.hdr); - } - if let Err(e) = self.upload(&c) { - tracing::warn!(error = %e, "frame upload failed"); - } - } - Some(DecodedFrame::Gpu(g)) => { - if g.hdr != self.hdr { - self.set_hdr(g.hdr); - } - if let Err(e) = self.bind_gpu(g) { - tracing::warn!(error = %e, "GPU frame bind failed"); - } - } - None => {} - } - self.draw(); - } - - /// Copy the decoded slice into our sampleable texture and build per-plane SRVs over it. The - /// decode array is decoder-only (NVIDIA won't bind a decoder array as a shader resource), so - /// it can't be sampled directly — one GPU-to-GPU copy makes the frame sampleable on every - /// vendor. D3D11 planar semantics: the slice is ONE subresource (both planes copy together), - /// and the source box is display-size (the array is coded-size; a full-resource copy would - /// size-mismatch and be silently dropped). - fn bind_gpu(&mut self, g: GpuFrame) -> Result<()> { - let src: ID3D11Texture2D = unsafe { - let raw = g.texture_ptr(); - ID3D11Texture2D::from_raw_borrowed(&raw) - .ok_or_else(|| anyhow!("null D3D11 texture"))? - .clone() - }; - self.ensure_sample_tex(g.width, g.height, g.ten_bit)?; - let dst = self.sample_tex.as_ref().unwrap().0.clone(); - // Even-aligned luma coordinates (NV12/P010 chroma is 2×2 subsampled). - let src_box = D3D11_BOX { - left: 0, - top: 0, - front: 0, - right: g.width & !1, - bottom: g.height & !1, - back: 1, - }; - unsafe { - self.context - .CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, g.index, Some(&src_box)); - } - let (fy, fc) = plane_formats(g.ten_bit); - let y = self.plane_srv(&dst, fy)?; - let c = self.plane_srv(&dst, fc)?; - self.write_csc_rows(g.color, g.ten_bit)?; - self.src_w = g.width; - self.src_h = g.height; - self.bound = Some(Bound { y, c }); - // Hold the frame until the next bind: its decode surface stays out of the reuse pool - // until this copy is queued ahead of any later decoder write (previous frame drops here). - self.gpu_frame = Some(g); - Ok(()) - } - - /// Ensure the sampleable copy texture matches the decoded frame's size + bit depth (NV12 for - /// 8-bit, P010 for 10-bit — the same format as the decode array, a `CopySubresourceRegion` - /// requirement), recreating it on a change. - fn ensure_sample_tex(&mut self, w: u32, h: u32, ten_bit: bool) -> Result<()> { - if matches!(&self.sample_tex, Some((_, tw, th, tb)) if *tw == w && *th == h && *tb == ten_bit) - { - return Ok(()); - } - let desc = D3D11_TEXTURE2D_DESC { - Width: w, - Height: h, - MipLevels: 1, - ArraySize: 1, - Format: if ten_bit { - DXGI_FORMAT_P010 - } else { - DXGI_FORMAT_NV12 - }, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, - CPUAccessFlags: 0, - MiscFlags: 0, - }; - let tex = unsafe { - let mut t = None; - self.device - .CreateTexture2D(&desc, None, Some(&mut t)) - .context("CreateTexture2D (sample target)")?; - t.ok_or_else(|| anyhow!("null sample texture"))? - }; - self.sample_tex = Some((tex, w, h, ten_bit)); - Ok(()) - } - - /// A shader-resource view over one plane of a single (non-array) NV12/P010 texture — the - /// R8/R8G8 (or R16/R16G16) format selects the luma vs. chroma plane (the D3D11 video - /// sub-format trick). - fn plane_srv( - &self, - tex: &ID3D11Texture2D, - format: DXGI_FORMAT, - ) -> Result { - let desc = D3D11_SHADER_RESOURCE_VIEW_DESC { - Format: format, - ViewDimension: D3D_SRV_DIMENSION_TEXTURE2D, - Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 { - Texture2D: D3D11_TEX2D_SRV { - MostDetailedMip: 0, - MipLevels: 1, - }, - }, - }; - unsafe { - let mut srv = None; - self.device - .CreateShaderResourceView(tex, Some(&desc), Some(&mut srv)) - .context("CreateShaderResourceView (plane)")?; - srv.ok_or_else(|| anyhow!("null SRV")) - } - } - - /// Upload a software-decoded frame's two planes into the dynamic plane textures (created to - /// match size/bit depth), feeding the same SRV slots + shaders as the GPU path. - fn upload(&mut self, frame: &CpuFrame) -> Result<()> { - let (w, h) = (frame.width, frame.height); - let rebuild = !matches!(&self.plane_tex, - Some((.., tw, th, tb)) if *tw == w && *th == h && *tb == frame.ten_bit); - if rebuild { - let (fy, fc) = plane_formats(frame.ten_bit); - let y = self.dynamic_tex(w, h, fy)?; - let uv = self.dynamic_tex(w.div_ceil(2), h.div_ceil(2), fc)?; - let y_srv = self.plane_srv(&y, fy)?; - let uv_srv = self.plane_srv(&uv, fc)?; - self.plane_tex = Some((y, uv, y_srv, uv_srv, w, h, frame.ten_bit)); - } - let (y, uv, y_srv, uv_srv, ..) = self.plane_tex.as_ref().unwrap(); - let bytes = if frame.ten_bit { 2 } else { 1 }; - self.map_rows(y, &frame.y, frame.y_stride, w as usize * bytes, h as usize)?; - self.map_rows( - uv, - &frame.uv, - frame.uv_stride, - w.div_ceil(2) as usize * 2 * bytes, - h.div_ceil(2) as usize, - )?; - let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone()); - self.write_csc_rows(frame.color, frame.ten_bit)?; - self.src_w = w; - self.src_h = h; - self.bound = Some(Bound { - y: y_srv, - c: uv_srv, - }); - self.gpu_frame = None; // drop any held GPU frame - Ok(()) - } - - fn dynamic_tex(&self, w: u32, h: u32, format: DXGI_FORMAT) -> Result { - let desc = D3D11_TEXTURE2D_DESC { - Width: w, - Height: h, - MipLevels: 1, - ArraySize: 1, - Format: format, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DYNAMIC, - BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, - MiscFlags: 0, - }; - unsafe { - let mut t = None; - self.device - .CreateTexture2D(&desc, None, Some(&mut t)) - .context("CreateTexture2D (plane)")?; - t.ok_or_else(|| anyhow!("null plane texture")) - } - } - - /// Recompute the bound frame's Y′CbCr→RGB rows from its CICP signaling and Map-discard them - /// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010 - /// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit). - fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> { - let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit); - unsafe { - let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); - self.context - .Map( - &self.csc_buf, - 0, - D3D11_MAP_WRITE_DISCARD, - 0, - Some(&mut mapped), - ) - .context("Map CSC constant buffer")?; - std::ptr::copy_nonoverlapping( - rows.as_ptr() as *const u8, - mapped.pData as *mut u8, - 48, // [[f32; 4]; 3] - ); - self.context.Unmap(&self.csc_buf, 0); - } - Ok(()) - } - - /// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`). - fn map_rows( - &self, - tex: &ID3D11Texture2D, - src: &[u8], - src_pitch: usize, - row_bytes: usize, - rows: usize, - ) -> Result<()> { - unsafe { - let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); - self.context - .Map(tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped)) - .context("Map plane texture")?; - let dst = mapped.pData as *mut u8; - let dst_pitch = mapped.RowPitch as usize; - let n = row_bytes.min(src_pitch); - for r in 0..rows { - std::ptr::copy_nonoverlapping( - src.as_ptr().add(r * src_pitch), - dst.add(r * dst_pitch), - n, - ); - } - self.context.Unmap(tex, 0); - } - Ok(()) - } - - fn draw(&mut self) { - let Ok(rtv) = self.rtv() else { - return; - }; - let (pw, ph) = (self.panel_w, self.panel_h); - unsafe { - let c = &self.context; - c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]); - if let Some(bound) = &self.bound { - // Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest. - let (ww, wh, vfw, vfh) = ( - pw as f32, - ph as f32, - self.src_w.max(1) as f32, - self.src_h.max(1) as f32, - ); - let scale = (ww / vfw).min(wh / vfh); - let (dw, dh) = (vfw * scale, vfh * scale); - let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0); - c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None); - let vp = D3D11_VIEWPORT { - TopLeftX: ox, - TopLeftY: oy, - Width: dw, - Height: dh, - MinDepth: 0.0, - MaxDepth: 1.0, - }; - c.RSSetViewports(Some(&[vp])); - c.IASetInputLayout(None); - c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); - c.VSSetShader(&self.vs, None); - c.PSSetShader(&self.ps_yuv, None); - c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())])); - c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())])); - c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); - c.Draw(3, 0); - } - let _ = self.swap.Present(1, DXGI_PRESENT(0)); - } - } - - /// Switch the swapchain between 8-bit SDR (B8G8R8A8, BT.709) and 10-bit HDR10 (R10G10B10A2, - /// ST.2084 PQ BT.2020). `ResizeBuffers` changes the back-buffer format in place, so the panel - /// binding (`set_swap_chain`) stays valid — no rebind. Both frame sources already produce - /// PQ-encoded BT.2020 for HDR, so the colour space is all the compositor needs. - fn set_hdr(&mut self, on: bool) { - self.rtv = None; // release back-buffer refs before ResizeBuffers - let format = if on { - DXGI_FORMAT_R10G10B10A2_UNORM - } else { - DXGI_FORMAT_B8G8R8A8_UNORM - }; - unsafe { - if let Err(e) = self.swap.ResizeBuffers( - 0, - self.panel_w, - self.panel_h, - format, - DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32), - ) { - tracing::warn!(error = %e, "ResizeBuffers for HDR switch failed"); - return; - } - let colorspace = if on { - DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 - } else { - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 - }; - if let Ok(sc3) = self.swap.cast::() { - // Only set a colour space the swapchain accepts for present (on an SDR desktop the - // DWM still tone-maps HDR10 → SDR, so leaving the default there is fine). - if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) { - if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 { - if let Err(e) = sc3.SetColorSpace1(colorspace) { - // A silent failure here presents PQ content as SDR gamma (crushed/dark) — - // surface it instead of swallowing it. - tracing::warn!(error = %e, ?colorspace, "SetColorSpace1 failed"); - } - } else if on { - tracing::warn!("swapchain rejects BT.2020 PQ present colour space (SDR display?) — DWM tone-maps"); - } - } - } - self.hdr = on; - if on { - self.apply_hdr_metadata(); - } - } - self.apply_dpi_matrix(); // belt-and-braces: keep the DIP mapping across the format switch - tracing::info!(hdr = on, "swapchain colour mode switched"); - } - - /// Push the current `DXGI_HDR_METADATA_HDR10` to the swapchain. Uses the source's received - /// mastering metadata when known, else a generic HDR10 baseline. Caller ensures HDR mode. - unsafe fn apply_hdr_metadata(&self) { - if let Ok(sc4) = self.swap.cast::() { - let md = self - .hdr_meta - .map(hdr_meta_to_dxgi) - .unwrap_or_else(generic_hdr10_metadata); - let bytes = std::slice::from_raw_parts( - &md as *const DXGI_HDR_METADATA_HDR10 as *const u8, - std::mem::size_of::(), - ); - if let Err(e) = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes)) { - tracing::warn!(error = %e, "SetHDRMetaData failed"); - } - } - } - - fn rtv(&mut self) -> Result { - if self.rtv.is_none() { - let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? }; - let rtv = unsafe { - let mut v = None; - self.device - .CreateRenderTargetView(&back, None, Some(&mut v)) - .context("CreateRenderTargetView")?; - v.unwrap() - }; - self.rtv = Some(rtv); - } - Ok(self.rtv.clone().unwrap()) - } -} - -impl Drop for Presenter { - fn drop(&mut self) { - if let Some(h) = self.waitable.take() { - unsafe { - let _ = CloseHandle(h); - } - } - } -} - -/// Luma + chroma plane view formats for NV12 (8-bit) vs P010 (10-in-16-bit). -fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) { - if ten_bit { - (DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM) - } else { - (DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM) - } -} - -/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the -/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was -/// created with (every `ResizeBuffers` must re-pass them). -fn create_composition_swapchain( - device: &ID3D11Device, - width: u32, - height: u32, -) -> Result<(IDXGISwapChain1, u32)> { - let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?; - let factory: IDXGIFactory2 = unsafe { - let adapter = dxdev.GetAdapter().context("GetAdapter")?; - adapter.GetParent().context("GetParent (IDXGIFactory2)")? - }; - let mut desc = DXGI_SWAP_CHAIN_DESC1 { - Width: width, - Height: height, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - Stereo: false.into(), - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, - BufferCount: 2, - Scaling: DXGI_SCALING_STRETCH, - SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, - // IGNORE (opaque), not PREMULTIPLIED: the video fills the panel with opaque RGB either way. - AlphaMode: DXGI_ALPHA_MODE_IGNORE, - Flags: DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32, - }; - unsafe { - match factory.CreateSwapChainForComposition(device, &desc, None) { - Ok(sc) => Ok((sc, desc.Flags)), - Err(e) => { - // Odd driver/WARP combinations can reject the waitable — fall back to plain - // Present(1) pacing rather than failing the stream page. - tracing::warn!(error = %e, "waitable swapchain rejected — creating without"); - desc.Flags = 0; - let sc = factory - .CreateSwapChainForComposition(device, &desc, None) - .context("CreateSwapChainForComposition")?; - Ok((sc, 0)) - } - } - } -} - -fn build_pipeline( - device: &ID3D11Device, -) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> { - let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?; - let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?; - unsafe { - let mut vs = None; - device - .CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs)) - .context("CreateVertexShader")?; - let mut ps_yuv = None; - device - .CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv)) - .context("CreatePixelShader (yuv)")?; - let sdesc = D3D11_SAMPLER_DESC { - Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, - AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, - AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, - AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, - MaxLOD: D3D11_FLOAT32_MAX, - ..Default::default() - }; - let mut sampler = None; - device - .CreateSamplerState(&sdesc, Some(&mut sampler)) - .context("CreateSamplerState")?; - Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap())) - } -} - -fn compile(src: &str, entry: &str, target: &str) -> Result { - let entry_c = std::ffi::CString::new(entry).unwrap(); - let target_c = std::ffi::CString::new(target).unwrap(); - let mut code = None; - let mut errors = None; - let r = unsafe { - D3DCompile( - src.as_ptr() as *const _, - src.len(), - PCSTR::null(), - None, - None, - PCSTR(entry_c.as_ptr() as *const u8), - PCSTR(target_c.as_ptr() as *const u8), - D3DCOMPILE_OPTIMIZATION_LEVEL3, - 0, - &mut code, - Some(&mut errors), - ) - }; - if r.is_err() { - let msg = errors - .as_ref() - .map(|b| unsafe { - let p = b.GetBufferPointer() as *const u8; - let n = b.GetBufferSize(); - String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string() - }) - .unwrap_or_default(); - return Err(anyhow!("D3DCompile {entry}: {msg}")); - } - code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode")) -} - -fn blob_bytes(blob: &ID3DBlob) -> &[u8] { - unsafe { - let p = blob.GetBufferPointer() as *const u8; - let n = blob.GetBufferSize(); - std::slice::from_raw_parts(p, n) - } -} - -/// True if any attached display is currently in HDR (BT.2020 PQ) mode. The client advertises HDR -/// caps only when this holds, so an SDR display gets a proper 8-bit BT.709 stream instead of PQ it -/// would mis-tone-map (the washed-out/dark failure); an HDR display self-tone-maps from the -/// mastering metadata. Coarse — checks ANY output, not the app's specific monitor; a mid-session -/// monitor move to/from HDR is a follow-up (the `Reconfigure` downgrade). -pub fn display_supports_hdr() -> bool { - unsafe { - let factory: IDXGIFactory1 = match CreateDXGIFactory1() { - Ok(f) => f, - Err(_) => return false, - }; - let mut ai = 0u32; - while let Ok(adapter) = factory.EnumAdapters1(ai) { - ai += 1; - let mut oi = 0u32; - while let Ok(output) = adapter.EnumOutputs(oi) { - oi += 1; - if let Ok(o6) = output.cast::() { - if let Ok(desc) = o6.GetDesc1() { - if desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 { - return true; - } - } - } - } - } - } - false -} - -/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in -/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`. -/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS -/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats -/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m² -/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no -/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session -/// gates on that check first, so both look at the same panel in the single-HDR-display case. -pub fn display_hdr_volume() -> Option { - let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16; - unsafe { - let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?; - let mut ai = 0u32; - while let Ok(adapter) = factory.EnumAdapters1(ai) { - ai += 1; - let mut oi = 0u32; - while let Ok(output) = adapter.EnumOutputs(oi) { - oi += 1; - let Ok(o6) = output.cast::() else { - continue; - }; - let Ok(desc) = o6.GetDesc1() else { continue }; - if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 { - continue; - } - return Some(punktfunk_core::quic::HdrMeta { - // ST.2086 order is G, B, R. - display_primaries: [ - [to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])], - [to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])], - [to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])], - ], - white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])], - max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round() - as u32, - min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round() - as u32, - max_cll: 0, - max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16, - }); - } - } - } - None -} - -/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display, -/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives. -fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 { - DXGI_HDR_METADATA_HDR10 { - RedPrimary: [35400, 14600], - GreenPrimary: [8500, 39850], - BluePrimary: [6550, 2300], - WhitePoint: [15635, 16450], - MaxMasteringLuminance: 1000, - MinMasteringLuminance: 1, // 0.0001-nit units → 0.0001 nits - MaxContentLightLevel: 1000, - MaxFrameAverageLightLevel: 400, - } -} - -/// Map the protocol's [`HdrMeta`](punktfunk_core::quic::HdrMeta) to `DXGI_HDR_METADATA_HDR10`. -/// Two careful conversions: HdrMeta stores primaries in **ST.2086 G,B,R order**, DXGI wants -/// **R,G,B**; and HdrMeta mastering luminance is in **0.0001-cd/m² units** while DXGI's -/// `MaxMasteringLuminance` is in **whole nits** (MinMasteringLuminance stays 0.0001-nit). Chromaticity -/// units (1/50000) and MaxCLL/MaxFALL (nits) match 1:1. -fn hdr_meta_to_dxgi(m: punktfunk_core::quic::HdrMeta) -> DXGI_HDR_METADATA_HDR10 { - let [g, b, r] = m.display_primaries; // ST.2086 order - DXGI_HDR_METADATA_HDR10 { - RedPrimary: r, - GreenPrimary: g, - BluePrimary: b, - WhitePoint: m.white_point, - MaxMasteringLuminance: m.max_display_mastering_luminance / 10_000, // 0.0001-nit → nit - MinMasteringLuminance: m.min_display_mastering_luminance, // already 0.0001-nit - MaxContentLightLevel: m.max_cll, - MaxFrameAverageLightLevel: m.max_fall, - } -} diff --git a/clients/windows/src/probe.rs b/clients/windows/src/probe.rs new file mode 100644 index 00000000..81137357 --- /dev/null +++ b/clients/windows/src/probe.rs @@ -0,0 +1,79 @@ +//! Network speed-test probe — the GUI's per-host "Test Network Speed…" ([`crate::app`]'s +//! speed page) and the `--headless --speed-test` CLI. +//! +//! Split out of the former in-process session module: the shared spawned-`punktfunk-session` +//! binary owns real streaming now, but the speed test is a shell-side, decode-less measurement +//! over the real data plane, so it stays here. [`decodable_codecs`] rode along for the same +//! reason — the probe connect still advertises which codecs this client can decode. + +use ffmpeg_next as ffmpeg; +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; +use std::time::{Duration, Instant}; + +/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264 +/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode. +pub fn decodable_codecs() -> u8 { + let _ = ffmpeg::init(); + let mut bits = 0u8; + for (id, bit) in [ + (ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC), + (ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264), + (ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1), + ] { + if ffmpeg::decoder::find(id).is_some() { + bits |= bit; + } + } + bits +} + +/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI): +/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is +/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to +/// completion. Run on a worker thread. +pub fn run_speed_probe( + addr: &str, + port: u16, + fp_hex: Option<&str>, + identity: (String, String), +) -> Result { + // Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU. + let pin = fp_hex.and_then(crate::trust::parse_hex32); + let c = NativeClient::connect( + addr, + port, + Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + CompositorPref::Auto, + GamepadPref::Auto, + 0, // bitrate_kbps: host default + 0, // video_caps: probe connect, nothing is decoded + 2, // audio_channels: stereo baseline + decodable_codecs(), + 0, // preferred_codec: no preference + None, // display_hdr: probe connect, nothing presents + None, // launch: no game + pin, + Some(identity), + Duration::from_secs(15), + ) + .map_err(|e| format!("connect: {e:?}"))?; + c.request_probe(3_000_000, 2_000) + .map_err(|e| format!("probe: {e:?}"))?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + std::thread::sleep(Duration::from_millis(250)); + if c.probe_result().done { + // Let the last UDP shards land before tearing down. + std::thread::sleep(Duration::from_millis(400)); + return Ok(c.probe_result()); + } + if Instant::now() > deadline { + return Err("probe timed out".to_string()); + } + } +} diff --git a/clients/windows/src/render.rs b/clients/windows/src/render.rs deleted file mode 100644 index bb243bd6..00000000 --- a/clients/windows/src/render.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! The dedicated video render thread: decoded frames flow session pump → bounded channel → here → -//! `Presenter::present`. Presenting off the XAML thread means UI jank (layout, input, dialogs) -//! never stalls video, and a filled present queue never blocks the UI thread — the two failure -//! modes of the old present-from-`on_rendering` design. -//! -//! Pacing: block on the channel (the host paces the stream), then on the swapchain's -//! frame-latency waitable (≤1 queued present — see `present.rs`), then drain to the NEWEST frame -//! so a stream faster than the display drops backlog before any GPU work. The UI thread only -//! writes panel size/DPI into [`RenderShared`] atomics; the loop applies them before the next -//! draw (and redraws the held frame after a resize — fresh back buffers are blank). - -use crate::present::Presenter; -use crate::session::{FrameRx, FrameTimes}; -use crossbeam_channel::RecvTimeoutError; -use std::sync::atomic::{AtomicBool, AtomicI64, 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, the end-to-end (capture→on-glass) -/// p50/p95 and the `display` stage (decoded→displayed) p50, all stamped post-`Present()`, 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 E2E_P50_US: AtomicU64 = AtomicU64::new(0); -static E2E_P95_US: AtomicU64 = AtomicU64::new(0); -static DISPLAY_P50_US: AtomicU64 = AtomicU64::new(0); - -/// The last render window's glass-side numbers (see the statics above) — the HUD's headline -/// (end-to-end) and trailing stage (display) come from here. -#[derive(Clone, Copy, Default, PartialEq)] -pub struct PresentStats { - /// Presents per second (includes resize redraws of a held frame). - pub fps: u32, - /// Frames dropped by the newest-wins drain this window (client-side pacing skips). - pub skipped: u32, - /// End-to-end capture→displayed p50, ms (host-clock corrected, measured directly). - pub e2e_p50_ms: f32, - /// End-to-end capture→displayed p95, ms. - pub e2e_p95_ms: f32, - /// `display` stage p50, ms: decoded → displayed, single-clock client-local. - pub display_p50_ms: f32, -} - -pub fn present_stats() -> PresentStats { - PresentStats { - fps: PRESENT_FPS.load(Ordering::Relaxed), - skipped: PRESENT_SKIPPED.load(Ordering::Relaxed), - e2e_p50_ms: E2E_P50_US.load(Ordering::Relaxed) as f32 / 1000.0, - e2e_p95_ms: E2E_P95_US.load(Ordering::Relaxed) as f32 / 1000.0, - display_p50_ms: DISPLAY_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 { - size_px: AtomicU64, - dpi: AtomicU32, - stop: AtomicBool, -} - -impl RenderShared { - pub fn new(width: u32, height: u32, dpi: u32) -> Arc { - Arc::new(RenderShared { - size_px: AtomicU64::new(pack(width, height)), - dpi: AtomicU32::new(dpi), - stop: AtomicBool::new(false), - }) - } - - pub fn set_size(&self, width: u32, height: u32) { - self.size_px.store(pack(width, height), Ordering::Relaxed); - } - - pub fn set_dpi(&self, dpi: u32) { - self.dpi.store(dpi, Ordering::Relaxed); - } - - fn snapshot(&self) -> (u32, u32, u32) { - let s = self.size_px.load(Ordering::Relaxed); - ((s >> 32) as u32, s as u32, self.dpi.load(Ordering::Relaxed)) - } -} - -fn pack(w: u32, h: u32) -> u64 { - ((w as u64) << 32) | h as u64 -} - -/// Handle owned by the stream page; stops + joins the thread on unmount (and on drop, so a -/// navigation away can't leak a presenting thread). -pub struct RenderThread { - shared: Arc, - join: Option>, -} - -impl RenderThread { - pub fn shared(&self) -> &Arc { - &self.shared - } - - pub fn stop_and_join(&mut self) { - self.shared.stop.store(true, Ordering::SeqCst); - if let Some(j) = self.join.take() { - let _ = j.join(); - } - } -} - -impl Drop for RenderThread { - fn drop(&mut self) { - self.stop_and_join(); - } -} - -/// Moves the presenter (COM interfaces, `!Send` by default) onto the render thread. Sound here: -/// the shared device + immediate context are multithread-protected (see `crate::gpu`), D3D/DXGI -/// objects are apartment-agile, and after this one handoff the swapchain/RTV/context calls happen -/// on exactly the render thread — the same single-owner discipline as `SharedDevice`. -struct SendPresenter(Presenter); -unsafe impl Send for SendPresenter {} - -/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our -/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid -/// (same math as the pump's host+network stage). A live handle (loaded per present) so -/// mid-stream clock re-syncs keep the number honest after an NTP step / drift. -pub fn spawn( - presenter: Presenter, - frames: FrameRx, - shared: Arc, - clock_offset_ns: Arc, -) -> RenderThread { - let boxed = SendPresenter(presenter); - let shared_w = shared.clone(); - let join = std::thread::Builder::new() - .name("pf-render".into()) - .spawn(move || run(boxed, frames, shared_w, clock_offset_ns)) - .expect("spawn render thread"); - RenderThread { - shared, - join: Some(join), - } -} - -fn now_ns() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0) -} - -/// The window DPI, polled ~1 Hz as belt-and-braces for a monitor move that changes DPI without a -/// `SizeChanged` (same DIP size on both screens). `None` when the window isn't up (headless). -fn poll_window_dpi() -> Option { - use windows::Win32::UI::HiDpi::GetDpiForWindow; - use windows::Win32::UI::WindowsAndMessaging::FindWindowW; - unsafe { - let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?; - match GetDpiForWindow(hwnd) { - 0 => None, - d => Some(d), - } - } -} - -fn run( - presenter: SendPresenter, - frames: FrameRx, - shared: Arc, - clock_offset_ns: Arc, -) { - let mut p = presenter.0; - let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter - let mut presented = 0u32; - let mut dropped = 0u32; - // 1 s tumbling windows: end-to-end (capture→displayed) and the display stage - // (decoded→displayed), sampled post-Present. Percentiles only (spec: stats-unification.md). - let mut e2e_us: Vec = Vec::with_capacity(256); - let mut display_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); - E2E_P50_US.store(0, Ordering::Relaxed); - E2E_P95_US.store(0, Ordering::Relaxed); - DISPLAY_P50_US.store(0, Ordering::Relaxed); - - loop { - if shared.stop.load(Ordering::SeqCst) { - break; - } - let first = match frames.recv_timeout(Duration::from_millis(50)) { - Ok(f) => Some(f), - Err(RecvTimeoutError::Timeout) => None, - Err(RecvTimeoutError::Disconnected) => break, - }; - - if last_dpi_poll.elapsed() >= Duration::from_secs(1) { - last_dpi_poll = Instant::now(); - if let Some(dpi) = poll_window_dpi() { - shared.set_dpi(dpi); - } - } - let snap = shared.snapshot(); - let resized = snap != applied && snap.0 > 0 && snap.1 > 0; - if resized { - p.resize(snap.0, snap.1, snap.2); - applied = snap; - } - if first.is_none() && !resized { - continue; // nothing new to show — don't burn GPU re-presenting a static frame - } - - // Throttle to the compositor: with ≤1 present outstanding this returns as DWM frees a - // slot, and frames decoded meanwhile are drained below so the newest is what's drawn. - if !p.wait_present_slot(1000) { - tracing::debug!("frame-latency waitable timed out — presenting anyway"); - } - let mut newest = first; - while let Ok(f) = frames.try_recv() { - if newest.is_some() { - dropped += 1; - } - newest = Some(f); - } - - // The session pump is the sole 0xCE consumer and stashes the latest here (rare updates). - if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() { - p.set_hdr_metadata(meta); - } - - let times: Option = newest.as_ref().map(|(_, t)| *t); - p.present(newest.map(|(f, _)| f)); - presented += 1; - if let Some(t) = times { - // The `displayed` point: post-Present() on this thread (the honest best-effort - // presentation instant on Windows — endpoint label `capture→on-glass`). - let displayed_ns = now_ns(); - // End-to-end = capture → displayed, host-clock corrected, measured directly - // (never the sum of stage percentiles). Clamped (0, 10 s). - let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128 - - t.pts_ns as i128) - .max(0) as u64; - if e2e > 0 && e2e < 10_000_000_000 { - e2e_us.push(e2e / 1000); - } - // `display` stage = decoded → displayed, single-clock client-local. - let disp = displayed_ns.saturating_sub(t.decoded_ns); - if disp < 10_000_000_000 { - display_us.push(disp / 1000); - } - } - - if window_start.elapsed() >= Duration::from_secs(1) { - e2e_us.sort_unstable(); - display_us.sort_unstable(); - let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0); - // p95 = sorted[min(len*95/100, len-1)] — the empty-window case falls to 0 via `get`. - let p95 = |v: &[u64]| { - v.get((v.len() * 95 / 100).min(v.len().saturating_sub(1))) - .copied() - .unwrap_or(0) - }; - tracing::debug!( - presented, - dropped, - e2e_p50_us = p50(&e2e_us), - e2e_p95_us = p95(&e2e_us), - display_p50_us = p50(&display_us), - "render window" - ); - PRESENT_FPS.store(presented, Ordering::Relaxed); - PRESENT_SKIPPED.store(dropped, Ordering::Relaxed); - E2E_P50_US.store(p50(&e2e_us), Ordering::Relaxed); - E2E_P95_US.store(p95(&e2e_us), Ordering::Relaxed); - DISPLAY_P50_US.store(p50(&display_us), Ordering::Relaxed); - window_start = Instant::now(); - presented = 0; - dropped = 0; - e2e_us.clear(); - display_us.clear(); - } - } - tracing::info!("render thread exiting"); -} diff --git a/clients/windows/src/session.rs b/clients/windows/src/session.rs deleted file mode 100644 index 033d2078..00000000 --- a/clients/windows/src/session.rs +++ /dev/null @@ -1,555 +0,0 @@ -//! Session controller: one worker thread runs connect → pump (video pull + decode, audio -//! pull + Opus decode, stats), feeding the UI over channels. The UI keeps the -//! `Arc` from the `Connected` event for direct input sends (no extra hop on -//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread: -//! video+audio here, rumble+hidout on the gamepad thread. -//! -//! Ported from the GTK Linux client; the platform-specific pieces are the video decoder -//! (software-only here) and the audio backend (WASAPI). The pump body is identical. - -use crate::audio; -use crate::video::{DecodedFrame, Decoder, DecoderPref}; -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; -use punktfunk_core::PunktfunkError; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -pub struct SessionParams { - pub host: String, - pub port: u16, - pub mode: Mode, - pub compositor: CompositorPref, - pub gamepad: GamepadPref, - pub bitrate_kbps: u32, - /// Requested audio channel count (2/6/8); the host echoes the resolved value. - pub audio_channels: u8, - /// Stream the default microphone to the host's virtual mic source. - pub mic_enabled: bool, - /// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream. - pub hdr_enabled: bool, - /// Which video decode backend to use (auto/hardware/software). - pub decoder: DecoderPref, - /// The user's preferred video codec (a `quic::CODEC_*` bit, `0` = auto). Soft — the host honors - /// it when it can emit it, else falls back; the resolved codec drives the decoder. - pub preferred_codec: u8, - /// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one). - pub pin: Option<[u8; 32]>, - pub identity: (String, String), - /// How long to wait for the handshake. The normal path uses a short budget; the - /// "request access" (delegated-approval) path uses a long one, because the host PARKS the - /// connection until the operator clicks Approve in its console (so this must exceed the - /// host's approval window — see `PENDING_APPROVAL_WAIT`). - pub connect_timeout: Duration, -} - -#[derive(Clone, Copy, Default, PartialEq)] -pub struct Stats { - /// AUs received (reassembled) per second — actual-elapsed-time denominator. - pub fps: f32, - /// Received payload goodput (excludes FEC overhead). - pub mbps: f32, - /// `decode` stage p50 over the last 1 s window: received → decoded, client-local clock. - pub decode_ms: f32, - /// `host+network` stage p50 over the last 1 s window: capture (`pts_ns`) → received, - /// host-clock corrected via `clock_offset_ns`. - pub hostnet_ms: f32, - /// `host` stage p50 (host capture→sent, from the per-AU 0xCF host-timing plane). Valid only - /// when `split` — an old host emits no 0xCF and the HUD keeps the combined stage. - pub host_ms: f32, - /// `network` stage p50 (`hostnet − host`, tiled per frame before taking the percentile). - /// Valid only when `split`. - pub net_ms: f32, - /// True when any 0xCF host timings matched received AUs this window — the HUD then renders - /// `host + network` instead of the combined `host+network` term. - pub split: bool, - /// True when `clock_offset_ns == 0` (host didn't answer the skew handshake / same host) — - /// the HUD appends `(same-host clock)` to the end-to-end line. - pub same_host: bool, - /// 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 { - Connected { - connector: Arc, - mode: Mode, - fingerprint: [u8; 32], - }, - /// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto` - /// error): for a pinned connect this is the fingerprint-changed signal, so the UI can - /// offer a re-pair (PIN) path rather than a dead-end error. - Failed { - msg: String, - trust_rejected: bool, - }, - Ended(Option), - Stats(Stats), -} - -/// Per-frame measurement points carried with a decoded frame to the render thread: the host -/// capture clock (`pts_ns`) and our local `decoded` stamp (wall-clock ns). Post-`Present()` the -/// render thread derives the `display` stage (displayed − decoded, single-clock) and the -/// end-to-end headline (displayed + clock_offset − pts) from them. -#[derive(Clone, Copy)] -pub struct FrameTimes { - pub pts_ns: u64, - pub decoded_ns: u64, -} - -/// Decoded frames + their measurement points, session pump → render thread (crossbeam so that -/// thread can block with a timeout — async-channel has no `recv_timeout`). -pub type FrameRx = crossbeam_channel::Receiver<(DecodedFrame, FrameTimes)>; - -pub struct SessionHandle { - pub events: async_channel::Receiver, - pub frames: FrameRx, - pub stop: Arc, -} - -/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI): -/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is -/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to -/// completion. Run on a worker thread. -pub fn run_speed_probe( - addr: &str, - port: u16, - fp_hex: Option<&str>, - identity: (String, String), -) -> Result { - // Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU. - let pin = fp_hex.and_then(crate::trust::parse_hex32); - let c = NativeClient::connect( - addr, - port, - Mode { - width: 1280, - height: 720, - refresh_hz: 60, - }, - CompositorPref::Auto, - GamepadPref::Auto, - 0, // bitrate_kbps: host default - 0, // video_caps: probe connect, nothing is decoded - 2, // audio_channels: stereo baseline - crate::video::decodable_codecs(), - 0, // preferred_codec: no preference - None, // display_hdr: probe connect, nothing presents - None, // launch: no game - pin, - Some(identity), - Duration::from_secs(15), - ) - .map_err(|e| format!("connect: {e:?}"))?; - c.request_probe(3_000_000, 2_000) - .map_err(|e| format!("probe: {e:?}"))?; - let deadline = Instant::now() + Duration::from_secs(10); - loop { - std::thread::sleep(Duration::from_millis(250)); - if c.probe_result().done { - // Let the last UDP shards land before tearing down. - std::thread::sleep(Duration::from_millis(400)); - return Ok(c.probe_result()); - } - if Instant::now() > deadline { - return Err("probe timed out".to_string()); - } - } -} - -pub fn start(params: SessionParams) -> SessionHandle { - let (ev_tx, ev_rx) = async_channel::unbounded(); - // Tiny frame queue, newest wins: the pump displaces the oldest when the renderer lags (it - // keeps a Receiver clone for exactly that). - let (frame_tx, frame_rx) = crossbeam_channel::bounded(2); - let stop = Arc::new(AtomicBool::new(false)); - let stop_w = stop.clone(); - let frame_rx_pump = frame_rx.clone(); - std::thread::Builder::new() - .name("punktfunk-session".into()) - .spawn(move || pump(params, ev_tx, frame_tx, frame_rx_pump, stop_w)) - .expect("spawn session thread"); - SessionHandle { - events: ev_rx, - frames: frame_rx, - stop, - } -} - -fn now_ns() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0) -} - -/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream -/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count -/// via the shared layout table. -enum AudioDec { - Stereo(opus::Decoder), - Surround(opus::MSDecoder), -} - -impl AudioDec { - fn new(channels: u8) -> Result { - if channels == 2 { - Ok(AudioDec::Stereo(opus::Decoder::new( - 48_000, - opus::Channels::Stereo, - )?)) - } else { - let l = punktfunk_core::audio::layout_for(channels, false); - Ok(AudioDec::Surround(opus::MSDecoder::new( - 48_000, l.streams, l.coupled, l.mapping, - )?)) - } - } - - fn decode_float( - &mut self, - input: &[u8], - out: &mut [f32], - fec: bool, - ) -> Result { - match self { - AudioDec::Stereo(d) => d.decode_float(input, out, fec), - AudioDec::Surround(d) => d.decode_float(input, out, fec), - } - } -} - -fn pump( - params: SessionParams, - ev_tx: async_channel::Sender, - frame_tx: crossbeam_channel::Sender<(DecodedFrame, FrameTimes)>, - frame_rx: FrameRx, - stop: Arc, -) { - // Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR - // mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still - // applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream - // rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020 - // PQ frames (P010 / X2BGR10). - let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr(); - if params.hdr_enabled && !hdr_active { - tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR"); - } - // With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it - // into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream - // arrives already inside its volume — the client presents it untouched. - // PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs. - let display_hdr = if hdr_active { - let vol = punktfunk_core::client::display_hdr_env_override() - .or_else(crate::present::display_hdr_volume); - if let Some(m) = vol { - tracing::info!( - max_nits = m.max_display_mastering_luminance / 10_000, - min_millinits = m.min_display_mastering_luminance / 10, - max_fall = m.max_fall, - "advertising this display's HDR volume to the host" - ); - } - vol - } else { - None - }; - let connector = match NativeClient::connect( - ¶ms.host, - params.port, - params.mode, - params.compositor, - params.gamepad, - params.bitrate_kbps, - if hdr_active { - punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR - } else { - 0 - }, - params.audio_channels, - crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) - params.preferred_codec, // the user's soft codec preference (0 = auto) - display_hdr, - None, // launch: the Windows client has no library picker yet - params.pin, - Some(params.identity), - params.connect_timeout, - ) { - Ok(c) => Arc::new(c), - Err(e) => { - let trust_rejected = matches!(e, PunktfunkError::Crypto); - let msg = match e { - PunktfunkError::Crypto => { - "Host identity rejected — wrong fingerprint, or the host requires pairing" - .to_string() - } - PunktfunkError::Timeout => "Connection timed out".to_string(), - other => format!("Connect failed: {other:?}"), - }; - let _ = ev_tx.send_blocking(SessionEvent::Failed { - msg, - trust_rejected, - }); - return; - } - }; - let _ = ev_tx.send_blocking(SessionEvent::Connected { - connector: connector.clone(), - mode: connector.mode(), - fingerprint: connector.host_fingerprint, - }); - - // Build the decoder for the codec the host resolved (never assume HEVC). - let codec_id = crate::video::ffmpeg_codec_id(connector.codec); - tracing::info!( - ?codec_id, - welcome_codec = connector.codec, - "negotiated video codec" - ); - let mut decoder = match Decoder::new(params.decoder, codec_id) { - Ok(d) => d, - Err(e) => { - let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}")))); - return; - } - }; - let mut hardware = decoder.is_hardware(); - let mut hdr = false; - // Audio is best-effort: a session without it still streams. Gamepads are the - // app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback - // from the host-RESOLVED channel count (never the request), so an older/clamping host that - // resolves stereo is decoded as stereo. - let channels = connector.audio_channels; - let player = audio::AudioPlayer::spawn(channels) - .map_err(|e| tracing::warn!(error = %e, "audio disabled")) - .ok(); - let mut opus_dec = AudioDec::new(channels) - .map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled")) - .ok(); - let _mic = params - .mic_enabled - .then(|| { - audio::MicStreamer::spawn(connector.clone()) - .map_err(|e| tracing::warn!(error = %e, "mic uplink disabled")) - .ok() - }) - .flatten(); - - // Force an immediate IDR (with in-band parameter sets) rather than waiting for the host's own - // first keyframe — under infinite GOP a late/missed IDR means the decoder sits on - // "PPS id out of range" (a black screen) until one arrives. - let _ = connector.request_keyframe(); - - // Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP - // step, drift) keep the capture-clock latency stats honest — never cached at session start. - let clock_offset_live = connector.clock_offset_shared(); - 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; - // 1 s tumbling stage windows (spec: design/stats-unification.md — percentiles, never means). - let mut hostnet_us: Vec = Vec::with_capacity(256); - let mut decode_us: Vec = Vec::with_capacity(256); - // Host/network split (Phase 2): received AUs awaiting their 0xCF host timing, `(pts_ns, - // hostnet_us)`, matched as the datagrams arrive. Bounded — an old host never sends any. - let mut pending_split: std::collections::VecDeque<(u64, u64)> = - std::collections::VecDeque::with_capacity(256); - let mut host_us_w: Vec = Vec::with_capacity(256); - let mut net_us_w: Vec = Vec::with_capacity(256); - let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels - // Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs. - let mut last_dropped = connector.frames_dropped(); - let mut last_kf_req: Option = None; - - let end: Option = loop { - if stop.load(Ordering::SeqCst) { - break None; - } - match connector.next_frame(Duration::from_millis(4)) { - Ok(frame) => { - // The `received` point: AU fully reassembled, handed to us, before decode. - let received_ns = now_ns(); - // Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame- - // invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers with a cheap - // clean P-frame instead of a full IDR. The frames_dropped keyframe path below is the - // backstop for when the recovery frame itself is lost. - let _ = connector.note_frame_index(frame.frame_index); - // fps = AUs received per second, Mb/s = received goodput (spec: counted at the - // received point, not the decoded one). - frames_n += 1; - bytes_n += frame.data.len() as u64; - // `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s). - let clock_offset = clock_offset_live.load(Ordering::Relaxed); - let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128) - .max(0) as u64; - if hostnet > 0 && hostnet < 10_000_000_000 { - hostnet_us.push(hostnet / 1000); - // Remember this AU for the 0xCF match below (host/network split). - pending_split.push_back((frame.pts_ns, hostnet / 1000)); - if pending_split.len() > 256 { - pending_split.pop_front(); - } - } - // A D3D11VA→software demotion (see `Decoder::decode`) starts a FRESH decoder that - // has none of the stream's parameter sets; under infinite GOP it would sit on - // "PPS id out of range" forever. Detect the transition and force a new IDR so the - // rebuilt decoder resynchronizes immediately. - let was_hw = decoder.is_hardware(); - let decoded = decoder.decode(&frame.data); - if was_hw && !decoder.is_hardware() { - tracing::info!("decoder demoted to software — requesting keyframe to resync"); - let _ = connector.request_keyframe(); - } - match decoded { - Ok(Some(decoded)) => { - // The `decoded` point: decoder output frame available. - let decoded_ns = now_ns(); - total_frames += 1; - hdr = decoded.hdr(); - // The backend can demote D3D11VA → software mid-session on a hardware error. - hardware = decoder.is_hardware(); - if total_frames == 1 { - let (w, h) = decoded.dims(); - tracing::info!( - width = w, - height = h, - path = if hardware { "d3d11va" } else { "software" }, - hdr, - "first frame decoded" - ); - } - // `decode` stage: received → decoded, single-clock client-local. - decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000); - // Newest wins: displace the oldest queued frame when the renderer lags. - if let Err(crossbeam_channel::TrySendError::Full(item)) = - frame_tx.try_send(( - decoded, - FrameTimes { - pts_ns: frame.pts_ns, - decoded_ns, - }, - )) - { - let _ = frame_rx.try_recv(); - let _ = frame_tx.try_send(item); - } - } - Ok(None) => {} - // Survivable (loss until the next IDR/RFI recovery) — keep feeding. - Err(e) => tracing::debug!(error = %e, "decode error (recovering)"), - } - } - Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), - Err(e) => break Some(format!("session: {e:?}")), - } - - // Loss recovery: under infinite GOP the only recovery keyframe is one we request. The - // reassembler drops unrecoverable AUs (frames_dropped); the decoder conceals the - // reference-missing delta frames that follow and returns Ok, so keying off a decode error - // rarely fires. Request an IDR when the drop count climbs, throttled. - let dropped = connector.frames_dropped(); - if dropped > last_dropped { - last_dropped = dropped; - let now = Instant::now(); - if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) { - last_kf_req = Some(now); - let _ = connector.request_keyframe(); - tracing::debug!(dropped, "requested keyframe (loss recovery)"); - } - } - - // Drain audio between frames (packets land every 5 ms; the queue holds 320 ms). - while let Ok(pkt) = connector.next_audio(Duration::ZERO) { - if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) { - match dec.decode_float(&pkt.data, &mut pcm, false) { - // `samples` is per-channel; the interleaved frame is `samples * channels`. - Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()), - Err(e) => tracing::debug!(error = %e, "opus decode"), - } - } - } - - // Drain the HDR static-metadata plane (0xCE): the source's real mastering display + content - // light level. Stash the latest for the UI-thread presenter to apply via SetHDRMetaData — - // this pump is the sole consumer of the plane. Rare (start + on change/keyframe). - while let Ok(meta) = connector.next_hdr_meta(Duration::ZERO) { - *crate::present::LATEST_HDR_META.lock().unwrap() = Some(meta); - } - - // Drain the per-AU host-timing plane (0xCF) and match by pts: `host` = the host's own - // capture→sent, `network` = our capture→received minus it — the two tile per frame - // (design/stats-unification.md Phase 2). An old host never emits any; `split` stays false - // and the HUD keeps the combined `host+network` stage. - while let Ok(t) = connector.next_host_timing(Duration::ZERO) { - if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) { - let (_, hn_us) = pending_split.remove(i).unwrap(); - host_us_w.push(t.host_us as u64); - net_us_w.push(hn_us.saturating_sub(t.host_us as u64)); - } - } - - if window_start.elapsed() >= Duration::from_secs(1) { - let secs = window_start.elapsed().as_secs_f32(); - hostnet_us.sort_unstable(); - decode_us.sort_unstable(); - host_us_w.sort_unstable(); - net_us_w.sort_unstable(); - let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0); - let (hostnet_p50, decode_p50) = (p50(&hostnet_us), p50(&decode_us)); - let (host_p50, net_p50) = (p50(&host_us_w), p50(&net_us_w)); - let split = !host_us_w.is_empty(); - tracing::debug!( - fps = frames_n, - hostnet_p50_us = hostnet_p50, - host_p50_us = host_p50, - net_p50_us = net_p50, - split, - decode_p50_us = decode_p50, - total_frames, - "stream window" - ); - let _ = ev_tx.try_send(SessionEvent::Stats(Stats { - fps: frames_n as f32 / secs, - mbps: bytes_n as f32 * 8.0 / 1e6 / secs, - decode_ms: decode_p50 as f32 / 1000.0, - hostnet_ms: hostnet_p50 as f32 / 1000.0, - host_ms: host_p50 as f32 / 1000.0, - net_ms: net_p50 as f32 / 1000.0, - split, - same_host: clock_offset_live.load(Ordering::Relaxed) == 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; - bytes_n = 0; - hostnet_us.clear(); - decode_us.clear(); - host_us_w.clear(); - net_us_w.clear(); - } - }; - - tracing::info!( - total_frames, - reason = end.as_deref().unwrap_or("user"), - "session ended" - ); - stop.store(true, Ordering::SeqCst); - let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); -} diff --git a/clients/windows/src/spawn.rs b/clients/windows/src/spawn.rs index a8e77b3c..dbfb4b93 100644 --- a/clients/windows/src/spawn.rs +++ b/clients/windows/src/spawn.rs @@ -6,10 +6,6 @@ //! [`SpawnEvent`]s a reader thread hands to the app's navigation closure: spinner until //! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected` //! routed to the re-pair PIN ceremony, `stats:` lines to the session status page. -//! -//! The legacy in-process D3D11VA presenter remains reachable via the -//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the -//! developer A/B baseline until its deletion. use std::io::BufRead as _; use std::process::{Child, Command, Stdio}; diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index 0408ba16..5bd05e0a 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -5,8 +5,7 @@ //! //! The shell is the settings file's only writer; the session only reads it. The shell's //! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files -//! still load via a serde alias in core, and the legacy in-process presenter is now -//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`). +//! still load via a serde alias in core. pub use pf_client_core::trust::{ hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts, diff --git a/clients/windows/src/video.rs b/clients/windows/src/video.rs deleted file mode 100644 index d0501ba6..00000000 --- a/clients/windows/src/video.rs +++ /dev/null @@ -1,653 +0,0 @@ -//! Video decode: reassembled HEVC access units → frames for the D3D11 presenter. -//! -//! Two backends, picked at session start (override via [`DecoderPref`] / the Settings UI): -//! -//! * **D3D11VA** (any GPU — the vendor-agnostic DXVA path on NVIDIA/AMD/Intel): libavcodec decodes -//! on the GPU into an `ID3D11Texture2D` decode array (decoder-only bind — NVIDIA rejects a -//! decoder array that is also a shader resource). The presenter copies each decoded slice into -//! its own sampleable NV12/P010 texture and converts YUV→RGB in a shader — one cheap GPU-to-GPU -//! copy per frame (no swscale, no CPU readback). The decode array is created by the process-wide -//! shared device ([`crate::gpu`]) the presenter also draws with, so the copy stays on-GPU. This -//! is the big latency/throughput win over software. -//! * **Software**: libavcodec on the CPU + swscale to the same planar layout the hardware path -//! produces (NV12, or P010 for 10-bit) — the presenter uploads the two planes and runs the SAME -//! YUV→RGB shaders, so hw/sw color math is identical. The fallback on a GPU-less box (WARP), -//! when D3D11VA init fails, or when a mid-session hardware error demotes us — the host's -//! IDR/RFI recovery resynchronizes on the next keyframe either way. -//! -//! D3D11VA viability is settled **before the session's first frame** by two probes: the adapter -//! must expose the negotiated codec's DXVA decode profile ([`decode_profile_supported`] — hwaccel -//! init otherwise only fails at the first AU, burning the IDR), and it must be able to create the -//! decode surface pool ([`d3d11va_decode_supported`]). Either failing commits to software decode -//! from frame one (a clean, gap-free stream) instead of dying mid-stream. -//! -//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no B-frames, in-band -//! parameter sets on every IDR), so decode is strictly one-in/one-out. -//! -//! HDR is detected in-band from the decoded frame's transfer characteristic (`SMPTE2084` / PQ in the -//! HEVC VUI) — the same signal every other punktfunk client keys off — not from a protocol field. - -use anyhow::{anyhow, bail, Context as _, Result}; -use ffmpeg::format::Pixel; -use ffmpeg::software::scaling; -use ffmpeg::util::frame::Video as AvFrame; -use ffmpeg_next as ffmpeg; -use pf_client_core::video::ColorDesc; -use std::ffi::c_void; -use std::ptr; -use windows::core::{Interface, GUID}; -use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11VideoDevice}; -use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12, DXGI_FORMAT_P010}; - -/// Which decode backend to use; the Settings UI persists this as a string. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum DecoderPref { - /// Try D3D11VA, fall back to software. - #[default] - Auto, - /// Force D3D11VA (error out if unavailable, for debugging). - Hardware, - /// Force software decode. - Software, -} - -impl DecoderPref { - pub fn from_name(s: &str) -> DecoderPref { - match s { - "hardware" => DecoderPref::Hardware, - "software" => DecoderPref::Software, - _ => DecoderPref::Auto, - } - } -} - -pub enum DecodedFrame { - Cpu(CpuFrame), - Gpu(GpuFrame), -} - -impl DecodedFrame { - pub fn dims(&self) -> (u32, u32) { - match self { - DecodedFrame::Cpu(c) => (c.width, c.height), - DecodedFrame::Gpu(g) => (g.width, g.height), - } - } - pub fn hdr(&self) -> bool { - match self { - DecodedFrame::Cpu(c) => c.hdr, - DecodedFrame::Gpu(g) => g.hdr, - } - } -} - -/// A software-decoded frame in the same planar layout the hardware path produces: an NV12 (or -/// P010 for 10-bit) luma plane + interleaved chroma plane, each with its swscale row stride -/// (≥ the row bytes — swscale pads rows for SIMD). The presenter uploads them into two dynamic -/// plane textures sampled by the same shaders as the D3D11VA path. -pub struct CpuFrame { - pub width: u32, - pub height: u32, - /// Luma plane (`W×H` samples, 1 byte each; 2 for 10-bit) + its row stride in bytes. - pub y: Vec, - pub y_stride: usize, - /// Interleaved chroma plane (`⌈W/2⌉×⌈H/2⌉` UV pairs) + its row stride in bytes. - pub uv: Vec, - pub uv_stride: usize, - /// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats. - pub ten_bit: bool, - /// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space. - pub hdr: bool, - /// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives - /// its Y′CbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux - /// host's RGB-input NVENC) no longer renders with BT.709 coefficients. - pub color: ColorDesc, -} - -/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder -/// wrote this frame into. The presenter copies the slice into its own sampleable texture and -/// converts YUV→RGB in a pixel shader. The underlying surface stays alive — and out of the decoder's -/// reuse pool — for exactly as long as `guard` (an `av_frame_clone` of the decoded frame) lives. -pub struct GpuFrame { - pub width: u32, - pub height: u32, - /// Texture-array slice this frame occupies (`AVFrame::data[1]`). - pub index: u32, - /// The decode pool is P010 (10 bits in the high bits) vs NV12 — from the frames context's - /// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the - /// source array exactly for `CopySubresourceRegion`. - pub ten_bit: bool, - /// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour - /// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`). - pub hdr: bool, - /// Per-frame CICP signaling — see [`CpuFrame::color`]. - pub color: ColorDesc, - guard: D3d11FrameGuard, -} - -impl GpuFrame { - /// The decoder's D3D11 texture array holding this frame's slice, borrowed from the live cloned - /// `AVFrame`. Construct the windows-rs interface on the thread that will use it (the render - /// thread): COM interfaces are `!Send`, but the raw pointer is fine to carry across threads. - pub fn texture_ptr(&self) -> *mut c_void { - unsafe { (*self.guard.0).data[0] as *mut c_void } - } -} - -/// Owns a cloned decoded `AVFrame` (which refs the D3D11 surface in the decoder pool). Dropping it -/// releases the surface back for reuse. The clone is plain refcounted data; freeing it from the -/// render thread is fine. -pub struct D3d11FrameGuard(*mut ffmpeg::ffi::AVFrame); -unsafe impl Send for D3d11FrameGuard {} -impl Drop for D3d11FrameGuard { - fn drop(&mut self) { - unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) }; - } -} - -enum Backend { - D3d11va(D3d11vaDecoder), - Software(SoftwareDecoder), -} - -pub struct Decoder { - backend: Backend, - /// The negotiated codec, so a mid-session D3D11VA→software demotion rebuilds for the same codec. - codec_id: ffmpeg::codec::Id, -} - -/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens. -pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id { - match wire { - punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264, - punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1, - _ => ffmpeg::codec::Id::HEVC, - } -} - -/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264 -/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode. -/// Deliberately NOT gated on the DXVA profiles: software decode covers anything FFmpeg can. -pub fn decodable_codecs() -> u8 { - let _ = ffmpeg::init(); - let mut bits = 0u8; - for (id, bit) in [ - (ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC), - (ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264), - (ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1), - ] { - if ffmpeg::decoder::find(id).is_some() { - bits |= bit; - } - } - bits -} - -impl Decoder { - pub fn new(pref: DecoderPref, codec_id: ffmpeg::codec::Id) -> Result { - ffmpeg::init().context("ffmpeg init")?; - if pref != DecoderPref::Software { - match D3d11vaDecoder::new(codec_id) { - Ok(d) => { - tracing::info!(?codec_id, "D3D11VA hardware decode active"); - return Ok(Decoder { - backend: Backend::D3d11va(d), - codec_id, - }); - } - Err(e) => { - if pref == DecoderPref::Hardware { - return Err(e.context("decoder=hardware but D3D11VA failed")); - } - tracing::info!(reason = %e, "D3D11VA unavailable — software decode"); - } - } - } - Ok(Decoder { - backend: Backend::Software(SoftwareDecoder::new(codec_id)?), - codec_id, - }) - } - - /// True for the GPU hardware backend (shown in the stream HUD). - pub fn is_hardware(&self) -> bool { - matches!(self.backend, Backend::D3d11va(_)) - } - - /// Feed one access unit; returns the decoded frame (the host's streams are one-in/one-out). A - /// software decode error after packet loss is survivable — keep feeding. A D3D11VA error demotes - /// to software for the rest of the session (the next IDR resynchronizes). - pub fn decode(&mut self, au: &[u8]) -> Result> { - match &mut self.backend { - Backend::D3d11va(d) => match d.decode(au) { - Ok(f) => Ok(f.map(DecodedFrame::Gpu)), - Err(e) => { - tracing::warn!(error = %e, "D3D11VA decode failed — falling back to software"); - self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?); - Ok(None) - } - }, - Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)), - } - } -} - -// --- DXVA decode-profile probe -------------------------------------------------------- - -/// DXVA decode-profile GUIDs (`dxva.h`), defined locally so no extra windows-rs feature or -/// metadata surface is pulled in for four constants. -const PROFILE_H264_VLD_NOFGT: GUID = GUID::from_u128(0x1b81be68_a0c7_11d3_b984_00c04f2e73c5); -const PROFILE_HEVC_VLD_MAIN: GUID = GUID::from_u128(0x5b11d51b_2f4c_4452_bcc3_09f2a1160cc0); -const PROFILE_HEVC_VLD_MAIN10: GUID = GUID::from_u128(0x107af0e0_ef1a_4d19_aba8_67a163073d13); -const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59_d6b8a6da5d2a); - -/// Does the shared device's adapter expose a DXVA decode profile for `codec_id`? Checked before -/// building the FFmpeg hwdevice because hwaccel selection (`get_format`) only runs on the FIRST -/// access unit — an unsupported profile would otherwise burn the opening IDR and recover through -/// the mid-stream demotion path instead of committing to software up front. Also logs (once) the -/// adapter's full profile list plus Main10 availability — the forensics for a new GPU/driver. -fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id) -> Result<()> { - let video: ID3D11VideoDevice = device - .cast() - .context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?; - let profiles: Vec = unsafe { - let n = video.GetVideoDecoderProfileCount(); - (0..n) - .filter_map(|i| video.GetVideoDecoderProfile(i).ok()) - .collect() - }; - log_profiles_once(&profiles); - - let (wanted, format, name): (GUID, DXGI_FORMAT, &str) = match codec_id { - ffmpeg::codec::Id::H264 => (PROFILE_H264_VLD_NOFGT, DXGI_FORMAT_NV12, "H.264 VLD NoFGT"), - ffmpeg::codec::Id::HEVC => (PROFILE_HEVC_VLD_MAIN, DXGI_FORMAT_NV12, "HEVC Main"), - ffmpeg::codec::Id::AV1 => (PROFILE_AV1_VLD_PROFILE0, DXGI_FORMAT_NV12, "AV1 Profile 0"), - other => bail!("no DXVA profile known for {other:?}"), - }; - let ok = profiles.contains(&wanted) - && unsafe { video.CheckVideoDecoderFormat(&wanted, format) } - .map(|b| b.as_bool()) - .unwrap_or(false); - if !ok { - bail!("adapter exposes no {name} decode profile"); - } - // 10-bit (a mid-session HDR upgrade needs Main10): informational — if it's missing the - // decode error → software demotion + keyframe re-request path covers the switch. - if codec_id == ffmpeg::codec::Id::HEVC { - let main10 = profiles.contains(&PROFILE_HEVC_VLD_MAIN10) - && unsafe { video.CheckVideoDecoderFormat(&PROFILE_HEVC_VLD_MAIN10, DXGI_FORMAT_P010) } - .map(|b| b.as_bool()) - .unwrap_or(false); - tracing::info!(main10, "HEVC Main10 (10-bit/HDR) decode profile"); - } - Ok(()) -} - -/// One-time dump of the adapter's DXVA decode profiles. -fn log_profiles_once(profiles: &[GUID]) { - use std::sync::atomic::{AtomicBool, Ordering}; - static ONCE: AtomicBool = AtomicBool::new(true); - if ONCE.swap(false, Ordering::Relaxed) { - let list: Vec = profiles.iter().map(|g| format!("{g:?}")).collect(); - tracing::info!(count = profiles.len(), profiles = ?list, "adapter DXVA decode profiles"); - } -} - -// --- software backend --------------------------------------------------------------- - -struct SoftwareDecoder { - decoder: ffmpeg::decoder::Video, - /// Rebuilt whenever the decoded format/size **or output format** changes (mid-stream - /// `Reconfigure`, or an 8↔10-bit flip): `(ctx, src_fmt, w, h, dst_fmt)`. - sws: Option<(scaling::Context, Pixel, u32, u32, Pixel)>, -} - -impl SoftwareDecoder { - fn new(codec_id: ffmpeg::codec::Id) -> Result { - let codec = ffmpeg::decoder::find(codec_id) - .ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?; - let mut ctx = ffmpeg::codec::Context::new_with_codec(codec); - unsafe { - let raw = ctx.as_mut_ptr(); - (*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32; - // Slice threading adds no frame delay (frame threading adds thread_count-1). - (*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE; - (*raw).thread_count = 0; // auto - } - let decoder = ctx.decoder().video().context("open video decoder")?; - Ok(SoftwareDecoder { decoder, sws: None }) - } - - fn decode(&mut self, au: &[u8]) -> Result> { - let packet = ffmpeg::Packet::copy(au); - self.decoder - .send_packet(&packet) - .map_err(|e| anyhow!("send_packet: {e}"))?; - let mut frame = AvFrame::empty(); - let mut out = None; - while self.decoder.receive_frame(&mut frame).is_ok() { - out = Some(self.convert(&frame)?); - } - Ok(out) - } - - /// Convert the decoded planar YUV to the hardware path's layout: NV12 for 8-bit, P010 for - /// 10-bit — a chroma interleave (and 10→16-high-bits shift), NOT a colour conversion. The - /// matrix/range/transfer handling all lives in the presenter's shaders, shared with the - /// D3D11VA path, so software frames are bit-comparable with hardware ones. - fn convert(&mut self, frame: &AvFrame) -> Result { - let (fmt, w, h) = (frame.format(), frame.width(), frame.height()); - // SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call. - let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) }; - let hdr = color.is_pq(); - // Source bit depth from the pix-fmt descriptor (stable FFmpeg public API). - let ten_bit = unsafe { - let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into()); - !desc.is_null() && (*desc).comp[0].depth > 8 - }; - let dst = if ten_bit { Pixel::P010LE } else { Pixel::NV12 }; - let rebuild = !matches!(&self.sws, Some((_, f, sw, sh, d)) if *f == fmt && *sw == w && *sh == h && *d == dst); - if rebuild { - let ctx = scaling::Context::get(fmt, w, h, dst, w, h, scaling::Flags::POINT) - .context("swscale context")?; - self.sws = Some((ctx, fmt, w, h, dst)); - } - let (sws, ..) = self.sws.as_mut().unwrap(); - let mut conv = AvFrame::empty(); - sws.run(frame, &mut conv).map_err(|e| anyhow!("sws: {e}"))?; - Ok(CpuFrame { - width: w, - height: h, - y: conv.data(0).to_vec(), - y_stride: conv.stride(0), - uv: conv.data(1).to_vec(), - uv_stride: conv.stride(1), - ten_bit, - hdr, - color, - }) - } -} - -// --- D3D11VA backend ------------------------------------------------------------------ -// -// Raw FFI: ffmpeg-next has no hwaccel wrappers. The COM-typed hwcontext structs are declared here -// (stable FFmpeg public ABI) rather than relied on from ffmpeg-sys bindgen — the generic -// AVHWDeviceContext / AVHWFramesContext (whose payload is an opaque `void *hwctx`) come from -// ffmpeg-sys, and we cast `hwctx` to the structs below. All owned pointers are freed in Drop; -// decoded surfaces transfer out through D3d11FrameGuard. - -const AVERROR_EAGAIN: i32 = -11; // -EAGAIN - -/// D3D11VA decode surface pool depth: the zero-reorder DPB (1–2 refs) + the bounded decoded channel -/// (2) + the frame the presenter currently holds (until its copy flushes) + one in-flight decode — -/// 12 is comfortable. A GPU that can't create the pool at all is gated out by -/// `d3d11va_decode_supported` and the session uses software decode. -const DECODE_POOL_SIZE: i32 = 12; - -/// `hwcontext_d3d11va.h` — `AVHWDeviceContext::hwctx`. Leaving `lock` null makes FFmpeg install an -/// `ID3D11Multithread` default lock + set multithread protection on `device_context` during init, -/// which is what lets the presenter share this device's immediate context from the render thread. -#[repr(C)] -struct AVD3D11VADeviceContext { - device: *mut c_void, // ID3D11Device* - device_context: *mut c_void, // ID3D11DeviceContext* - video_device: *mut c_void, // ID3D11VideoDevice* - video_context: *mut c_void, // ID3D11VideoContext* - lock: *mut c_void, // void (*)(void*) - unlock: *mut c_void, // void (*)(void*) - lock_ctx: *mut c_void, -} - -/// `hwcontext_d3d11va.h` — `AVHWFramesContext::hwctx`. The header is explicit: "The user must at -/// least set D3D11_BIND_DECODER if the frames context is to be used for video decoding" — a -/// user-built frames context gets NO default (BindFlags 0 → `CreateTexture2D` E_INVALIDARG); the -/// automatic OR-in lives only in libavcodec's own frames-param path, which we bypass. -#[repr(C)] -struct AVD3D11VAFramesContext { - texture: *mut c_void, // ID3D11Texture2D* (null → FFmpeg allocates the pool) - bind_flags: u32, // UINT BindFlags - misc_flags: u32, // UINT MiscFlags - texture_infos: *mut c_void, // AVD3D11FrameDescriptor* (FFmpeg-managed) -} - -/// `D3D11_BIND_DECODER` — the decode pool's ONLY bind flag. Adding `D3D11_BIND_SHADER_RESOURCE` -/// is what NVIDIA rejects on a decoder texture ARRAY; the presenter samples via its own copy. -const BIND_DECODER: u32 = 0x200; - -fn averr(what: &str, code: i32) -> anyhow::Error { - anyhow!("{what}: {}", ffmpeg::Error::from(code)) -} - -/// libavcodec's `get_format` callback: pick the D3D11 hw surface format and nothing else. -/// Deliberately does NOT build a frames context — with `hw_device_ctx` set and `hw_frames_ctx` -/// left null, libavcodec derives the decode pool itself (`ff_decode_get_hw_frames_ctx`), applying -/// every vendor quirk: DXVA surface alignment (128 for HEVC/AV1), DPB-based pool sizing, and the -/// decoder-only `D3D11_BIND_DECODER` flags. A hand-built context validated on NVIDIA was rejected -/// by Intel at the first `SubmitDecoderBuffers` (E_INVALIDARG) — the vendor-proof path is the one -/// the ffmpeg CLI/mpv ship. Returning anything but `AV_PIX_FMT_D3D11` aborts hardware decode → -/// the session demotes to software. -unsafe extern "C" fn get_format_d3d11( - avctx: *mut ffmpeg::ffi::AVCodecContext, - mut list: *const ffmpeg::ffi::AVPixelFormat, -) -> ffmpeg::ffi::AVPixelFormat { - use ffmpeg::ffi::*; - unsafe { - if (*avctx).hw_device_ctx.is_null() { - return AVPixelFormat::AV_PIX_FMT_NONE; - } - while *list != AVPixelFormat::AV_PIX_FMT_NONE { - if *list == AVPixelFormat::AV_PIX_FMT_D3D11 { - return AVPixelFormat::AV_PIX_FMT_D3D11; - } - list = list.add(1); - } - AVPixelFormat::AV_PIX_FMT_NONE - } -} - -/// Predict whether D3D11VA decode will work by doing EXACTLY what the decoder's `get_format` does — -/// allocate an `AVHWFramesContext` (decoder-only pool, no shader-resource bind) and initialize it, -/// which creates the real NV12 decode surface array. On a GPU/driver that can't create the pool this -/// fails here, up front, so the session commits to software decode from the first frame (a clean, -/// gap-free stream) rather than decoding the IDR then dying mid-stream on a texture error that a -/// software demotion can't reliably recover from (the host's infinite GOP won't re-send an IDR). -unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool { - use ffmpeg::ffi::*; - unsafe { - let frames_ref = av_hwframe_ctx_alloc(hw_device); - if frames_ref.is_null() { - return false; - } - let frames = (*frames_ref).data as *mut AVHWFramesContext; - (*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11; - (*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; - (*frames).width = 1920; - (*frames).height = 1152; // 128-aligned 1080p surface (the HEVC DXVA alignment, see get_format) - (*frames).initial_pool_size = DECODE_POOL_SIZE; - // Decoder-only — matches get_format exactly. - let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext; - (*fhw).bind_flags = BIND_DECODER; - let r = av_hwframe_ctx_init(frames_ref); - let mut fr = frames_ref; - av_buffer_unref(&mut fr); - r >= 0 - } -} - -struct D3d11vaDecoder { - ctx: *mut ffmpeg::ffi::AVCodecContext, - hw_device: *mut ffmpeg::ffi::AVBufferRef, - packet: *mut ffmpeg::ffi::AVPacket, - frame: *mut ffmpeg::ffi::AVFrame, -} - -// Single-owner pointers, only touched from the session pump thread. -unsafe impl Send for D3d11vaDecoder {} - -impl D3d11vaDecoder { - fn new(codec_id: ffmpeg::codec::Id) -> Result { - use ffmpeg::ffi; - let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?; - if !shared.hardware { - bail!("shared device is WARP (no hardware video decode)"); - } - // The adapter must expose the codec's DXVA profile — checked here, not at the first AU. - decode_profile_supported(&shared.device, codec_id)?; - unsafe { - // Build a D3D11VA hwdevice context around the *shared* device, so decoded textures live - // on the same device the presenter samples + draws with. - let hw_device = - ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); - if hw_device.is_null() { - bail!("av_hwdevice_ctx_alloc(D3D11VA) failed"); - } - let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext; - let d3dctx = (*devctx).hwctx as *mut AVD3D11VADeviceContext; - // Hand FFmpeg an owned ref to the device + immediate context (it Releases them when the - // hwdevice ctx is freed). `into_raw()` transfers a +1 ref without releasing. - (*d3dctx).device = shared.device.clone().into_raw(); - (*d3dctx).device_context = shared.context.clone().into_raw(); - // lock left null → FFmpeg installs the ID3D11Multithread default lock in init. - let r = ffi::av_hwdevice_ctx_init(hw_device); - if r < 0 { - let mut hw = hw_device; - ffi::av_buffer_unref(&mut hw); - bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r)); - } - - // Up-front viability probe (see `d3d11va_decode_supported`): a GPU/driver that can't - // create the decode surface pool commits to software NOW, so it decodes cleanly from the - // first frame instead of failing mid-stream (which a demotion can't reliably recover). - if !d3d11va_decode_supported(hw_device) { - let mut hw = hw_device; - ffi::av_buffer_unref(&mut hw); - bail!("GPU can't create the D3D11VA decode surface pool — using software decode"); - } - - let codec = ffi::avcodec_find_decoder(codec_id.into()); - if codec.is_null() { - let mut hw = hw_device; - ffi::av_buffer_unref(&mut hw); - bail!("no {codec_id:?} decoder"); - } - let ctx = ffi::avcodec_alloc_context3(codec); - (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device); - (*ctx).get_format = Some(get_format_d3d11); - (*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32; - // hwaccel: threads only add latency. - (*ctx).thread_count = 1; - // On top of the DPB-based pool libavcodec sizes for us: the bounded decoded channel - // (2) + the frame the presenter holds until its copy flushes + margin. - (*ctx).extra_hw_frames = 4; - let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut()); - if r < 0 { - let mut ctx = ctx; - ffi::avcodec_free_context(&mut ctx); - let mut hw = hw_device; - ffi::av_buffer_unref(&mut hw); - bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r)); - } - Ok(D3d11vaDecoder { - ctx, - hw_device, - packet: ffi::av_packet_alloc(), - frame: ffi::av_frame_alloc(), - }) - } - } - - fn decode(&mut self, au: &[u8]) -> Result> { - use ffmpeg::ffi; - unsafe { - let r = ffi::av_new_packet(self.packet, au.len() as i32); - if r < 0 { - return Err(averr("av_new_packet", r)); - } - ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len()); - let r = ffi::avcodec_send_packet(self.ctx, self.packet); - ffi::av_packet_unref(self.packet); - if r < 0 { - return Err(averr("send_packet", r)); - } - let mut out = None; - loop { - let r = ffi::avcodec_receive_frame(self.ctx, self.frame); - if r == AVERROR_EAGAIN { - break; - } - if r < 0 { - return Err(averr("receive_frame", r)); - } - out = Some(self.lift()?); // newest wins; older guards drop here - ffi::av_frame_unref(self.frame); - } - Ok(out) - } - } - - /// Lift the decoded D3D11 surface into a `GpuFrame`. `data[0]` is the texture array, `data[1]` - /// the slice index. We `av_frame_clone` so the surface stays referenced (kept out of the reuse - /// pool) until the presenter drops the guard. - unsafe fn lift(&mut self) -> Result { - use ffmpeg::ffi; - unsafe { - if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 { - bail!("decoder returned a software frame (no D3D11 surface)"); - } - // SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call. - let color = ColorDesc::from_raw(self.frame); - let hdr = color.is_pq(); - let ten_bit = { - let hwfc = (*self.frame).hw_frames_ctx; - !hwfc.is_null() - && (*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format - == ffi::AVPixelFormat::AV_PIX_FMT_P010LE - }; - let cloned = ffi::av_frame_clone(self.frame); - if cloned.is_null() { - bail!("av_frame_clone failed"); - } - let frame = GpuFrame { - width: (*self.frame).width as u32, - height: (*self.frame).height as u32, - index: (*self.frame).data[1] as usize as u32, - ten_bit, - hdr, - color, - guard: D3d11FrameGuard(cloned), - }; - log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit); - Ok(frame) - } - } -} - -impl Drop for D3d11vaDecoder { - fn drop(&mut self) { - use ffmpeg::ffi; - unsafe { - ffi::av_packet_free(&mut self.packet); - ffi::av_frame_free(&mut self.frame); - ffi::avcodec_free_context(&mut self.ctx); - ffi::av_buffer_unref(&mut self.hw_device); - } - } -} - -/// One-time dump of the first decoded surface's layout — so a new GPU/driver combination's real -/// format (slice index range, HDR/bit-depth) is visible in the logs without a debugger. -fn log_layout_once(width: u32, height: u32, index: u32, hdr: bool, ten_bit: bool) { - use std::sync::atomic::{AtomicBool, Ordering}; - static ONCE: AtomicBool = AtomicBool::new(true); - if ONCE.swap(false, Ordering::Relaxed) { - tracing::info!( - width, - height, - slice = index, - hdr, - ten_bit, - "D3D11VA first frame" - ); - } -}