diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 60e43bd8..68b8f984 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -1,4 +1,4 @@ -//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path. +//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`). //! //! Two dedicated threads, because both stacks are tied to their thread: //! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime @@ -7,9 +7,13 @@ //! drops; ashpd's `Session` has no `Drop`); //! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames. //! -//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx -//! frames leave the pipewire thread over a bounded channel. The authoritative frame size -//! comes from the negotiated PipeWire format, not the portal's size hint. +//! The portal hands the PipeWire remote fd + node id to the pipewire thread; frames leave that +//! thread through a ONE-DEEP OVERWRITING slot (`FrameSlot`) plus a wakeup edge — not the bounded +//! `sync_channel(8)` this once used, which was drop-NEWEST and so handed a stalled consumer stale +//! frames (see `FrameSlot`'s own note). The payload is not necessarily BGRx either: the negotiation +//! can settle on packed RGB, NV12, YUV444 or 10-bit PQ, and on a dmabuf passthrough it never touches +//! the CPU. The authoritative frame size comes from the negotiated PipeWire format, not the portal's +//! size hint. //! //! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a //! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context @@ -18,7 +22,9 @@ //! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end, //! or a retried/failed pipeline build) therefore leaves nothing behind on either side. -// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof +// program). This file itself has none — the FFI lives in the child modules declared at the bottom +// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers. #![deny(clippy::undocumented_unsafe_blocks)] use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy}; @@ -173,8 +179,9 @@ pub struct PortalCapturer { /// capture, not per frame. negotiation_confirmed: bool, /// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s - /// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the - /// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta). + /// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the SDR + /// downgrade for THIS [`Self::hdr_source`] only, not process-wide) and by + /// [`hdr_meta`](Capturer::hdr_meta). hdr_offer: bool, /// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer) /// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag. @@ -463,7 +470,10 @@ fn spawn_pipewire( let zerocopy = allow_zerocopy && pf_zerocopy::enabled(); // HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR // offer is dropped — SDR capture, loudly. - let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1"); + // The shared parser, not a bare `== "1"` compare — matching `PUNKTFUNK_PIPEWIRE_NV12` below. + // A bare compare silently ignored `PUNKTFUNK_FORCE_SHM=true`/`=on`/`=yes`, so the knob looked + // set and did nothing. + let force_shm = pf_host_config::env_on("PUNKTFUNK_FORCE_SHM").unwrap_or(false); let want_hdr = if want_hdr && force_shm { tracing::warn!( "HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \ @@ -555,6 +565,14 @@ impl Capturer for PortalCapturer { // every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland // that appears later is adopted, and follows whichever one gamescope draws the pointer on. // `frame_size` lets it map root-space coordinates into frame space. + // + // Idempotent by construction. The contract says "called once", but nothing enforced it, and a + // second call evaluated `spawn` BEFORE dropping the old source: two readers then published + // into the same slot for the construction window, and a `spawn` that returned `None` destroyed + // a perfectly good reader outright. + if self._gs_cursor.is_some() { + return; + } self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn( targets, Arc::clone(&self.signals.cursor_live), @@ -655,6 +673,11 @@ impl Capturer for PortalCapturer { if let Ok(mut slot) = self.slot.lock() { *slot = None; } + // Clear the stall clock for the same reason the mailbox is flushed: a pooled capturer + // whose previous stream ended mid-stall carried that `Instant` into the next one, so the + // first `try_latest` that saw `!streaming` found the 1500 ms grace already expired and + // reported capture loss on a stream that had been running for microseconds. + self.stall_since = None; } } @@ -776,8 +799,9 @@ impl PortalCapturer { // The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR // mode between the probe and the negotiation, the compositor pre-dates the // GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30. - // Latch the process-wide SDR downgrade so the next session (Moonlight - // auto-reconnects) negotiates SDR instead of re-running this same timeout. + // Latch the SDR downgrade for THIS source (`HdrSource`, not process-wide — one + // shared flag let either Linux HDR source disable the other) so the next session + // (Moonlight auto-reconnects) negotiates SDR instead of re-running this timeout. super::note_hdr_capture_failed(self.hdr_source); Err(anyhow!( "no PipeWire frame within {within}s (node {}): the compositor never \ diff --git a/crates/pf-capture/src/linux/pipewire.rs b/crates/pf-capture/src/linux/pipewire.rs index 2a631136..ade8861c 100644 --- a/crates/pf-capture/src/linux/pipewire.rs +++ b/crates/pf-capture/src/linux/pipewire.rs @@ -1506,7 +1506,20 @@ pub fn pipewire_thread( { return; } - if ud.info.parse(param).is_ok() { + // Parse ONCE — `parse` takes `&mut self` — and report a failure instead of swallowing it. + // On `Err`, `negotiated` stays false and `format`/`modifier`/`frame_size` keep their + // previous values, so the capture dies on the generic "the compositor offered no format + // this consumer accepts" timeout — sending the operator hunting a format mismatch when + // the real fault was a malformed Format pod we DID accept. + let parsed = ud.info.parse(param); + if let Err(e) = &parsed { + tracing::error!( + error = %e, + "pipewire: failed to parse the negotiated Format pod — capture will time out \ + with no usable format" + ); + } + if parsed.is_ok() { ud.signals.negotiated.store(true, Ordering::Relaxed); // A (re)negotiation replaces the buffer pool: every cached per-buffer import // (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on diff --git a/crates/pf-capture/src/linux/pw_cursor.rs b/crates/pf-capture/src/linux/pw_cursor.rs index dcdcddd0..248c71d2 100644 --- a/crates/pf-capture/src/linux/pw_cursor.rs +++ b/crates/pf-capture/src/linux/pw_cursor.rs @@ -197,6 +197,15 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 { return; } + // SPA's second "no image data" signal, distinct from the `bitmap_offset == 0` position-only + // case above: `spa_meta_bitmap.offset` is the offset of the PIXELS within the bitmap struct, + // and 0 means there are none. Without this, `pix_off == 0` made the pixel extent start at the + // `spa_meta_bitmap` header itself, so a producer signalling an invisible pointer got its own + // header words (format/size/stride/offset) decoded and cached as the cursor bitmap. In bounds, + // so not unsound — just garbage pixels blitted into every later frame. + if pix_off == 0 { + return; + } let row = bw as usize * 4; let stride = if stride < row { row } else { stride }; let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size) @@ -327,7 +336,8 @@ pub(super) fn composite_cursor_rgb10( } /// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched -/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame — +/// position. Cheap: a straight-alpha blit over at most 1024×1024 pixels (the accepted cap; real +/// cursors are ≤96 px), clipped to the frame — /// the whole point of cursor-as-metadata (no forced full-frame composite on the producer). pub(super) fn composite_cursor( tight: &mut [u8], diff --git a/crates/pf-capture/src/linux/pw_pods.rs b/crates/pf-capture/src/linux/pw_pods.rs index f6ef62c8..f22b27cc 100644 --- a/crates/pf-capture/src/linux/pw_pods.rs +++ b/crates/pf-capture/src/linux/pw_pods.rs @@ -377,7 +377,8 @@ pub(super) fn build_dmabuf_buffers() -> Result> { /// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as /// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired /// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't -/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor. +/// attach it (harmless). Size is a range up to a 1024×1024 bitmap — see the note on `max` below for +/// why this is not the "bigger than any real cursor" 256² it used to be. pub(super) fn build_cursor_meta_param() -> Result> { fn meta_size(w: u32, h: u32) -> i32 { (std::mem::size_of::() diff --git a/crates/pf-capture/src/windows/dxgi.rs b/crates/pf-capture/src/windows/dxgi.rs index 5ff74c96..190664a4 100644 --- a/crates/pf-capture/src/windows/dxgi.rs +++ b/crates/pf-capture/src/windows/dxgi.rs @@ -554,9 +554,11 @@ impl HdrP010Converter { let mut ps_uv = None; device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?; let sd = D3D11_SAMPLER_DESC { - // POINT: the Y pass samples a single texel centre exactly, and the UV pass does its OWN - // 2x2 box average via 4 explicit taps at texel centres (offset half a texel). Point - // sampling keeps each tap exact; the averaging is in the shader, not the sampler. + // POINT: the Y pass samples a single texel centre exactly, and the UV pass takes its OWN + // two explicit taps on the 2x2 block's LEFT column (left-cositing) and averages them. + // Point sampling keeps each tap exact; the averaging is in the shader, not the sampler. + // (It was a 4-tap CENTER-sited 2x2 box until that was found to shift chroma by half a + // luma pixel — see `HDR_P010_UV_PS`.) Filter: D3D11_FILTER_MIN_MAG_MIP_POINT, AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index ed1d674f..abeeae0e 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -337,6 +337,7 @@ use channel::ChannelBroker; use descriptor::{DescriptorPoller, DisplayDescriptor}; use stall::{StallEvidence, StallWatch}; +/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. pub struct IddPushCapturer { device: ID3D11Device, context: ID3D11DeviceContext, @@ -652,14 +653,18 @@ impl IddPushCapturer { } /// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR - /// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ + /// state plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ /// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR → /// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC /// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the - /// SM-side CSC, because the video processor can only produce subsampled output). We do NOT - /// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the - /// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 + - /// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that + /// SM-side CSC, because the video processor can only produce subsampled output). The + /// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open + /// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample + /// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only + /// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the + /// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning + /// survives only in the CODEC choice: an HDR-negotiated H.26x session still follows a host + /// "Use HDR" flip in either direction.) HDR and 4:4:4 now COMPOSE: an HDR display that /// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to /// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to /// 4:2:0 *after* the Welcome had already promised 4:4:4.) @@ -969,7 +974,7 @@ impl IddPushCapturer { }, Usage: D3D11_USAGE_DEFAULT, // RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and - // NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own + // NVENC registers it as encode input. (PyroWave uses its own // shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.) BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, CPUAccessFlags: 0, @@ -1970,9 +1975,12 @@ impl Capturer for IddPushCapturer { fn pipeline_depth(&self) -> usize { // 2 = one frame deferred: submit N+1 (capture + convert/copy into a fresh out-ring texture) while // NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe. - // `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight - // always has its own texture). - pf_host_config::config().idd_depth.clamp(1, OUT_RING) + // `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining). The ceiling is `OUT_RING - 1`, NOT + // `OUT_RING`: `d` frames in flight need `d + 1` textures, because the rotation has to hand out a + // slot that is not one of the `d` still being encoded. Clamping to `OUT_RING` admitted depth 3 on + // a 3-slot ring, where `repeat_last`'s rotation lands back on the slot NVENC is reading and the + // convert overwrites it in place — torn frames, silently, with no error anywhere. + pf_host_config::config().idd_depth.clamp(1, OUT_RING - 1) } fn capture_target_id(&self) -> Option { diff --git a/crates/pf-capture/src/windows/idd_push/channel.rs b/crates/pf-capture/src/windows/idd_push/channel.rs index f370110f..f72e3476 100644 --- a/crates/pf-capture/src/windows/idd_push/channel.rs +++ b/crates/pf-capture/src/windows/idd_push/channel.rs @@ -160,7 +160,18 @@ impl ChannelBroker { event: HANDLE, slots: &[HostSlot], ) -> Result<()> { - debug_assert!(slots.len() <= control::RING_LEN_USIZE); + // An ERROR, not a `debug_assert`: in a release build the assert is compiled out and the + // over-long slice instead panics on `req.texture_handles[k]` in the middle of + // `duplicate_and_deliver` — after handles have already been planted in WUDFHost. That panic + // unwinds straight past the reap below, leaking every duplicate made so far into the driver + // process. Refuse before the first duplication, while there is nothing to reap. + if slots.len() > control::RING_LEN_USIZE { + anyhow::bail!( + "frame channel: {} ring slots exceeds the wire limit of {}", + slots.len(), + control::RING_LEN_USIZE + ); + } let mut req = control::SetFrameChannelRequest { target_id, generation, diff --git a/crates/pf-capture/src/windows/idd_push/cursor.rs b/crates/pf-capture/src/windows/idd_push/cursor.rs index c4df1aed..80e79ae7 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor.rs @@ -42,7 +42,9 @@ impl CursorShared { /// the section itself (owned by `self`); the caller duplicates it into the WUDFHost. pub(super) fn create(target_id: u32) -> Result { // SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is - // mapped once and unmapped never (the capturer's life = the session's life). + // mapped once here and unmapped exactly once by `MappedSection::drop` (which unmaps before + // closing the mapping handle). No borrow into the view outlives the `MappedSection`: every + // access goes through `&self` accessors on the owner. let section = unsafe { let map = CreateFileMappingW( INVALID_HANDLE_VALUE, diff --git a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs index 47e8b809..d3dc7f49 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs @@ -55,8 +55,10 @@ struct Shape { serial: u64, } -/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only -/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the +/// Off-thread GDI cursor poller. Samples `GetCursorInfo` every [`Self::INTERVAL`] (4 ms, ~250 Hz — +/// see that constant for why 16 ms was the bug), rasterises the `HCURSOR` when its handle value +/// changes and when [`Self::EXTENT_PROBE`] catches a resize under a STABLE handle, and publishes a +/// ready [`pf_frame::CursorOverlay`] snapshot; the /// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone /// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place /// on the capture/encode thread). @@ -186,7 +188,6 @@ fn run( // against, and this poller outlives all of them. `None` keeps the last good value — a // transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would // report every position invisible. - // let fresh = pf_win_display::win_display::source_desktop_rect(target_id); if let Some(fresh) = fresh { if fresh != rect { @@ -302,7 +303,14 @@ fn run( serial: s.serial, hot_x: s.hot_x, hot_y: s.hot_y, - visible: showing && in_rect, + // `handle != 0` is part of "visible", not just of "worth rasterising": `SetCursor(NULL)` + // — how a game or a video player hides the pointer for its own window — leaves + // `CURSOR_SHOWING` set with a NULL `hCursor`. Judging on the flags alone published + // `visible: true` carrying the last shape we rasterised, so the composite path blended a + // ghost arrow into a game that had hidden its cursor, and the forward path told the + // client to draw one too. Every rasterise gate below already tests this; the published + // verdict has to agree with them. + visible: showing && in_rect && handle != 0, } }); *slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay; diff --git a/crates/pf-capture/src/windows/idd_push/descriptor.rs b/crates/pf-capture/src/windows/idd_push/descriptor.rs index 0b62417c..3866ba0c 100644 --- a/crates/pf-capture/src/windows/idd_push/descriptor.rs +++ b/crates/pf-capture/src/windows/idd_push/descriptor.rs @@ -6,7 +6,6 @@ use super::*; -/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. /// The display descriptor the capture loop follows: live HDR state + active resolution of the /// virtual target. #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/pf-capture/src/windows/idd_push/dxgkrnl_etw.rs b/crates/pf-capture/src/windows/idd_push/dxgkrnl_etw.rs index ae20d598..2b4f03e9 100644 --- a/crates/pf-capture/src/windows/idd_push/dxgkrnl_etw.rs +++ b/crates/pf-capture/src/windows/idd_push/dxgkrnl_etw.rs @@ -142,7 +142,12 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) { (*record).EventHeader.ProcessId, ) }; - let mut ring = RING.lock().unwrap(); + // Poison-tolerant, and that is load-bearing rather than tidy: this is an `extern "system"` + // callback invoked from an OS thread, so a panic here unwinds across an FFI boundary and + // ABORTS the host process. `unwrap()` made a single poisoned lock turn every subsequent event + // delivery into a hard abort — a diagnostic taking down capture. Nothing else under this lock + // can panic, so recovering the guard also makes the poison unreachable in the first place. + let mut ring = RING.lock().unwrap_or_else(|e| e.into_inner()); if ring.len() == RING_CAP { ring.pop_front(); } diff --git a/crates/pf-capture/src/windows/idd_push/open.rs b/crates/pf-capture/src/windows/idd_push/open.rs index 9aa3a95f..9173cc5a 100644 --- a/crates/pf-capture/src/windows/idd_push/open.rs +++ b/crates/pf-capture/src/windows/idd_push/open.rs @@ -152,8 +152,11 @@ impl IddPushCapturer { } /// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the - /// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA - /// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the + /// virtual display); on FAILURE the keepalive is handed BACK so the caller decides the display's fate + /// itself — retire it, or reuse the monitor for a retry — instead of this function tearing it down + /// (audit §5.1 — no more 20 s black bail). There is no second capture path to fall back TO: DDA was + /// removed (see `lib.rs`), and `punktfunk-host`'s caller drops the returned keepalive under + /// `.context("IDD-push capture open (no fallback)")`. "Failure" includes the /// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch). #[allow(clippy::too_many_arguments)] pub fn open( @@ -666,7 +669,7 @@ impl IddPushCapturer { // wait for the first compose) until the capturer drops with the session. _display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(), // Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand - // it back to the caller for the DDA fallback (audit §5.1). + // it back to the caller to retire or reuse the display (audit §5.1). _keepalive: Box::new(()), }; // The HDR SDR-white reference for the composited cursor, queried ONCE here rather than @@ -675,15 +678,15 @@ impl IddPushCapturer { me.refresh_sdr_white_scale(); // Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach // failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a - // format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA), - // instead of next_frame's 20 s black-then-bail. + // format/size the ring can't match) becomes an open failure the caller handles by retiring the + // display, instead of next_frame's 20 s black-then-bail. me.wait_for_attach()?; Ok(me) } } /// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published - /// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 + + /// a first frame**, else fail so the caller can retire the display and rebuild (audit §5.1 + /// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix). /// /// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case: diff --git a/crates/pf-capture/src/windows/idd_push/probes.rs b/crates/pf-capture/src/windows/idd_push/probes.rs index 6042bf52..d87a03e9 100644 --- a/crates/pf-capture/src/windows/idd_push/probes.rs +++ b/crates/pf-capture/src/windows/idd_push/probes.rs @@ -53,7 +53,9 @@ use super::stall::ProbeWindow; /// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call /// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at -/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage. +/// `completed_at`. Capped at 512 samples: at the fastest producer's ~20 Hz that is ~26 s of +/// coverage, ~51 s for the 100 ms loops — comfortably longer than the seconds-old windows a stall +/// report asks for, but NOT the "several minutes" this used to claim. struct Ring { samples: Mutex>, } diff --git a/crates/pf-capture/src/windows/idd_push/stall.rs b/crates/pf-capture/src/windows/idd_push/stall.rs index af3c4357..9ed9c735 100644 --- a/crates/pf-capture/src/windows/idd_push/stall.rs +++ b/crates/pf-capture/src/windows/idd_push/stall.rs @@ -317,7 +317,8 @@ impl StallWatch { /// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus /// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed. const RECENT: usize = 8; - /// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow — + /// The RECENT pre-gap frames must all fit in this span (8 frames spanning 400 ms is 7 intervals, + /// so the real bar is ≈ ≥ 17.5 fps flow — /// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage). const ACTIVE_SPAN: Duration = Duration::from_millis(400); /// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the