Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61547b512a | ||
|
|
166c93c079 | ||
|
|
8749bd1396 |
@@ -175,46 +175,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
// MARK: Escape-drop re-lock
|
||||
//
|
||||
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape — the platform's
|
||||
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
|
||||
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
|
||||
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
|
||||
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
|
||||
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
private var pointerLockWasEngaged = false
|
||||
/// Attempts spent in the current re-lock burst, and when the burst began.
|
||||
private var pointerRelockAttempt = 0
|
||||
private var pointerRelockBurstStart: CFTimeInterval = 0
|
||||
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
|
||||
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
|
||||
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
|
||||
/// teleporting the host's to the pointer's absolute position.
|
||||
private var pointerRelockPending = false
|
||||
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
|
||||
/// presents the system with a genuine false→true transition instead of re-asserting a value it
|
||||
/// already holds. See `requestPointerRelock()`.
|
||||
private var pointerLockForcedOff = false
|
||||
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
|
||||
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
|
||||
/// to today's click-to-recapture, rather than retrying forever.
|
||||
private static let pointerRelockAttemptLimit = 3
|
||||
private static let pointerRelockBurstWindow: CFTimeInterval = 2
|
||||
/// Gap between attempts in a burst — long enough for the system to answer the previous
|
||||
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
|
||||
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
|
||||
/// next attempt evaluates.
|
||||
private static let pointerRelockRetryDelay: TimeInterval = 0.2
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -300,7 +260,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
|
||||
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
|
||||
@@ -423,11 +383,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// is the exact mirror of the GCMouse handlers, which fire only while locked.
|
||||
streamView.onPointerMoveAbs = { [weak self] p in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
|
||||
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
|
||||
// resume. Motion only — BUTTONS still forward (they carry no position, so a click during
|
||||
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
|
||||
guard !self.pointerRelockPending else { return }
|
||||
self.inputCapture?.sendMouseAbs(
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
@@ -738,24 +693,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// change and capture toggle. Main queue.
|
||||
private func syncPointerLock() {
|
||||
let locked = pointerLockEngaged() == true
|
||||
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
|
||||
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
|
||||
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
|
||||
// duration to win a lock that isn't coming. A first grant is already driven by the chain
|
||||
// engage in setCaptured/viewDidAppear.
|
||||
if locked {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
// Capture is gone (or the lock was never ours) — settle, and let the next capture
|
||||
// start from a clean "never held" slate.
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
// gcMouseForwarding flips false its release handler is gated off, so flush any held
|
||||
@@ -767,83 +704,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
|
||||
if iosInputDebug {
|
||||
iosInputLog.debug(
|
||||
"""
|
||||
pointer lock isLocked=\(locked, privacy: .public) \
|
||||
captured=\(self.captured, privacy: .public) \
|
||||
relockPending=\(self.pointerRelockPending, privacy: .public) \
|
||||
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
|
||||
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
|
||||
private func requestPointerRelock() {
|
||||
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
|
||||
// saying we don't qualify, not the Esc key — re-asking would be noise, and the qualifying
|
||||
// states (foreground, appearance, reparent) each re-resolve on their own already.
|
||||
guard view.window?.windowScene?.activationState == .foregroundActive else {
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
let now = CACurrentMediaTime()
|
||||
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
|
||||
// is the backstop for the pathological case where a grant is immediately revoked again and
|
||||
// re-arms us. Even then this stays timer-driven at a few Hz — never a spin.
|
||||
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
|
||||
pointerRelockBurstStart = now
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
pointerRelockPending = false
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
pointerRelockPending = true
|
||||
let escalate = pointerRelockAttempt > 1
|
||||
// Deferred a turn so a ⌘⎋ whose GC keystroke lands after the system's unlock notification
|
||||
// has already cleared `captured` — then the guard below drops this attempt instead of
|
||||
// fighting the user's own release.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
|
||||
// The grant landed, or the capture went away under us (⌘⎋ / ⌃⌥⇧Q / resign).
|
||||
// Settle through the one decision point rather than returning with `pending` still
|
||||
// set — that flag hides the cursor, so it must never outlive the burst.
|
||||
self.syncPointerLock()
|
||||
return
|
||||
}
|
||||
if escalate {
|
||||
// Re-asserting a value the system already holds didn't take. Present a real
|
||||
// false→true transition instead — the documented way to change your mind about the
|
||||
// lock — and re-anchor the chain in case a reparent broke the downward walk to us.
|
||||
// Held for a beat rather than cleared on the next turn: the system resolves the
|
||||
// property asynchronously, and a same-turn flip back to true can be coalesced into
|
||||
// no transition at all. We are already unlocked, so the false pass costs nothing.
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
} else {
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
// A GRANT arrives as a didChange → syncPointerLock, which settles the burst and makes
|
||||
// this retry a no-op. Routed back through syncPointerLock (not straight into another
|
||||
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
|
||||
// place that does it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
|
||||
[weak self] in
|
||||
guard let self, self.pointerRelockPending else { return }
|
||||
self.syncPointerLock()
|
||||
}
|
||||
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -863,11 +724,7 @@ extension StreamViewController: UIPointerInteractionDelegate {
|
||||
// host renders its own cursor from GCMouse deltas and a visible local one would just
|
||||
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
|
||||
// pointer is forwarded as an absolute position, both cursors tracking together.
|
||||
// …except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
|
||||
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
|
||||
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
|
||||
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
|
||||
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
|
||||
captured && pointerLockEngaged() == true ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -612,10 +612,7 @@ pub fn open_portal_monitor(
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
|
||||
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
|
||||
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
|
||||
/// "pointer hidden" the composited/forwarded cursor must honor.
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -628,7 +625,6 @@ pub fn open_virtual_output(
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -640,7 +636,6 @@ pub fn open_virtual_output(
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -72,11 +72,6 @@ struct CaptureOpts {
|
||||
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
||||
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
||||
expect_exact_dims: bool,
|
||||
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
|
||||
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
|
||||
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
|
||||
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
|
||||
cursor_id0_hides: bool,
|
||||
}
|
||||
|
||||
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
||||
@@ -306,10 +301,6 @@ impl PortalCapturer {
|
||||
want_444: false,
|
||||
want_hdr,
|
||||
expect_exact_dims: false,
|
||||
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
|
||||
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
|
||||
// one through here yet; the virtual-output path below carries the real flag.
|
||||
cursor_id0_hides: false,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
@@ -325,8 +316,7 @@ impl PortalCapturer {
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
|
||||
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -338,7 +328,6 @@ impl PortalCapturer {
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -346,7 +335,6 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
@@ -362,7 +350,6 @@ impl PortalCapturer {
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
|
||||
@@ -811,7 +811,6 @@ pub fn pipewire_thread(
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
..
|
||||
} = opts;
|
||||
crate::pwinit::ensure_init();
|
||||
@@ -986,7 +985,7 @@ pub fn pipewire_thread(
|
||||
yuv444: want_444,
|
||||
linear_nv12_failed: false,
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::new(cursor_id0_hides),
|
||||
cursor: CursorState::default(),
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
|
||||
@@ -39,23 +39,9 @@ pub(super) struct CursorState {
|
||||
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
|
||||
/// process-wide latch made the second session's triage read as "no meta".
|
||||
seen_meta: bool,
|
||||
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
|
||||
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
|
||||
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
|
||||
/// [`note_cursor_id`].
|
||||
id0_hides: bool,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// The per-stream state, declaring which `id == 0` contract the producer follows
|
||||
/// ([`Self::id0_hides`]).
|
||||
pub(super) fn new(id0_hides: bool) -> CursorState {
|
||||
CursorState {
|
||||
id0_hides,
|
||||
..CursorState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
@@ -93,31 +79,6 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
|
||||
/// meta region (position, bitmap) is worth parsing.
|
||||
///
|
||||
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
|
||||
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
|
||||
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
|
||||
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
|
||||
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
|
||||
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
|
||||
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
|
||||
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
|
||||
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
|
||||
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
|
||||
/// Windows has its own CURSOR_SUPPRESSED source).
|
||||
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
|
||||
if id == 0 {
|
||||
if cursor.id0_hides {
|
||||
cursor.visible = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cursor.visible = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
@@ -160,9 +121,16 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if !note_cursor_id(cursor, id) {
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
@@ -399,44 +367,9 @@ mod tests {
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
seen_meta: true,
|
||||
id0_hides: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn id_zero_hides_only_on_a_rewriting_producer() {
|
||||
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
|
||||
// a game or Big Picture hiding the pointer must reach the stream.
|
||||
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
kwin.id0_hides = true;
|
||||
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
|
||||
let o = kwin.overlay().expect("bitmap stays cached across a hide");
|
||||
assert!(!o.visible, "KWin id 0 must hide the overlay");
|
||||
// The pointer coming back re-shows the SAME cached bitmap.
|
||||
assert!(note_cursor_id(&mut kwin, 1));
|
||||
assert!(kwin.overlay().expect("still cached").visible);
|
||||
|
||||
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
|
||||
// (honoring them flickered the cursor off between hovers, on-glass round 5).
|
||||
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
|
||||
assert!(!note_cursor_id(&mut mutter, 0));
|
||||
assert!(
|
||||
mutter.overlay().expect("cached").visible,
|
||||
"a stale-meta producer's id 0 must NOT hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_zero_before_any_bitmap_yields_no_overlay() {
|
||||
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
|
||||
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
|
||||
let mut c = CursorState::new(true);
|
||||
assert!(!note_cursor_id(&mut c, 0));
|
||||
assert!(c.overlay().is_none());
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -110,11 +110,6 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta
|
||||
// is an authoritative "pointer hidden" — the caller derives it from the backend that created
|
||||
// `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own
|
||||
// backend). See `pf_capture`'s `cursor_id0_hides` contract.
|
||||
cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
|
||||
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
|
||||
@@ -137,7 +132,6 @@ pub fn capture_virtual_output(
|
||||
want.hdr,
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
vout.expect_exact_dims,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,9 +171,6 @@ pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
// Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no
|
||||
// `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source.
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -294,7 +285,6 @@ pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
_cursor_id0_hides: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
@@ -559,7 +559,6 @@ pub fn mirror_test(args: &[String]) -> Result<()> {
|
||||
vout,
|
||||
fmt,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")?;
|
||||
cap.set_active(true);
|
||||
|
||||
@@ -570,7 +570,6 @@ fn open_gs_mirror_source(
|
||||
vout,
|
||||
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach a capturer to the mirrored monitor")
|
||||
}
|
||||
@@ -783,7 +782,6 @@ fn open_gs_virtual_source(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
|
||||
@@ -1815,10 +1815,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// on purpose — it survives every in-loop rebuild path (session switch, mode/stall rebuilds,
|
||||
// encoder backoff), so a mid-stream rebuild keeps the acquired lock.
|
||||
let mut phase_ctl = PhaseController::new();
|
||||
// Frame-driven wire-rate cap (see [`PaceBudget`]): a loop local like `phase_ctl`, and for the
|
||||
// same reason — it must survive every in-loop rebuild path so a mid-stream rebuild can't
|
||||
// reopen the overshoot. Bounded burst (CAP) is all a rebuild gap can buy.
|
||||
let mut pace = PaceBudget::new(std::time::Instant::now());
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
@@ -3121,12 +3117,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
|
||||
// Frame-driven mode (T1.1) re-anchors to the ACTUAL submit — arrivals are the clock, and a
|
||||
// fixed `+= interval` grid would drift against them and squeeze the pacing budget; the
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail). The rate-cap
|
||||
// charge lives under the same guard as the tail's gate: the legacy tick paces by its grid
|
||||
// alone, and charging it without ever accruing would bank unbounded debt that stalls the
|
||||
// loop if a rebuild later flips the capturer to arrival-wait.
|
||||
// legacy tick keeps its fixed grid (with the catch-up reset in the tail).
|
||||
next = if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||
pace.charge();
|
||||
std::time::Instant::now() + interval
|
||||
} else {
|
||||
next + interval
|
||||
@@ -3485,12 +3477,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||
// to a full interval — ~half on average), sleep only to the rate floor and then
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor leaves per-gap jitter
|
||||
// headroom; the `pace` budget pins the long-run AVERAGE at the pacing rate — the
|
||||
// floor alone let a source that always has a frame pending (an HZ_MULT-overdriven
|
||||
// display under uncapped content) settle at 0.9-interval spacing, 1.11× the
|
||||
// negotiated rate on the wire, frames the client's panel can only drop. The
|
||||
// +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// wake on the capture's actual arrival. The 0.9×interval floor caps the encode
|
||||
// rate at ~1.11× target when the source runs faster (compositor Hz > session fps);
|
||||
// the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
|
||||
// client liveness) at 1.5×interval cadence and bounds control-servicing latency.
|
||||
//
|
||||
// Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is
|
||||
@@ -3500,12 +3489,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// period becomes interval + encode (≈158 fps off a 240 Hz source; 360 Hz → ~200).
|
||||
// An async encoder (NVENC) returns from submit in ≈0, so t_cap ≈ post-submit and this
|
||||
// is a no-op for it — which is why H.26x already holds full rate. Arrival-anchoring
|
||||
// lets the synchronous encode overlap the interval; the budget, not the floor, is
|
||||
// what bounds the sustained rate.
|
||||
let earliest = std::cmp::max(
|
||||
t_cap + interval.mul_f32(0.9),
|
||||
pace.earliest(std::time::Instant::now(), interval),
|
||||
);
|
||||
// lets the synchronous encode overlap the interval; the ≥0.9×interval spacing from
|
||||
// the last grab still caps the rate at ~1.11× target.
|
||||
let earliest = t_cap + interval.mul_f32(0.9);
|
||||
if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) {
|
||||
std::thread::sleep(d);
|
||||
}
|
||||
@@ -4015,59 +4001,6 @@ fn pacing_hz(session_hz: u32, achieved_hz: u32) -> u32 {
|
||||
achieved_hz.min(session_hz).max(1)
|
||||
}
|
||||
|
||||
/// Long-run rate limiter for the frame-driven trigger (T1.1): pins the AVERAGE encode rate at the
|
||||
/// pacing rate while the 0.9×interval arrival floor keeps its jitter headroom.
|
||||
///
|
||||
/// The floor alone bounds only each gap, so a source that always has a frame pending — a display
|
||||
/// overdriven by `PUNKTFUNK_VDISPLAY_HZ_MULT`, uncapped content — settles at 0.9-interval spacing:
|
||||
/// 1.11× the negotiated rate on the wire (field report: 132 fps on a 120 fps session, frames the
|
||||
/// client's 120 Hz panel can only drop). Credit accrues at one frame per interval of real elapsed
|
||||
/// time (capped at [`Self::CAP`]) and every submitted frame spends one; a grab may run early only
|
||||
/// against banked credit, so per-gap jitter still passes while the average cannot exceed the
|
||||
/// pacing rate. A source at or below the pacing rate banks credit faster than it spends and is
|
||||
/// never delayed.
|
||||
struct PaceBudget {
|
||||
/// Banked frames, in `[-1.0, CAP]`. Transiently dips below 0 when a grab spent credit it had
|
||||
/// only partly banked; the owed fraction is repaid before the next grab.
|
||||
credit: f32,
|
||||
/// When credit last accrued (the previous [`Self::earliest`] call).
|
||||
last: std::time::Instant,
|
||||
}
|
||||
|
||||
impl PaceBudget {
|
||||
/// Burst allowance: at most this many frames may follow a stall back-to-back before the
|
||||
/// bucket re-gates. One frame of instant catch-up plus the floor's own headroom.
|
||||
const CAP: f32 = 1.25;
|
||||
|
||||
fn new(now: std::time::Instant) -> PaceBudget {
|
||||
PaceBudget {
|
||||
credit: Self::CAP,
|
||||
last: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accrue the elapsed credit and return the earliest instant the next grab may run: `now`
|
||||
/// once a full frame is banked, else the missing fraction of an interval out.
|
||||
fn earliest(
|
||||
&mut self,
|
||||
now: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
) -> std::time::Instant {
|
||||
let secs = interval.as_secs_f32();
|
||||
if secs > 0.0 {
|
||||
let accrued = now.duration_since(self.last).as_secs_f32() / secs;
|
||||
self.credit = (self.credit + accrued).min(Self::CAP);
|
||||
}
|
||||
self.last = now;
|
||||
now + interval.mul_f32((1.0 - self.credit).max(0.0))
|
||||
}
|
||||
|
||||
/// One frame submitted — spend its credit.
|
||||
fn charge(&mut self) {
|
||||
self.credit -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
@@ -4189,19 +4122,9 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
//
|
||||
// KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative
|
||||
// "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow
|
||||
// outlives every in-game/Big Picture hide (0.22.0 field report). Derived from the backend
|
||||
// (correct for pooled reuse too — a kept display only matches its own backend).
|
||||
let cursor_id0_hides = vd.name() == pf_vdisplay::Compositor::Kwin.id();
|
||||
let mut capturer = crate::capture::capture_virtual_output(
|
||||
vout,
|
||||
plan.output_format(),
|
||||
plan.capture,
|
||||
cursor_id0_hides,
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
|
||||
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
|
||||
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
|
||||
@@ -4347,105 +4270,6 @@ mod tests {
|
||||
assert_eq!(pacing_hz(60, 0), 1);
|
||||
}
|
||||
|
||||
/// Drive [`PaceBudget`] against a source that ALWAYS has a frame pending (the overdriven
|
||||
/// display + uncapped content case): each cycle grabs the instant the gate opens, the next
|
||||
/// `earliest` call runs right after (encode folded into the wait, like the loop). Returns the
|
||||
/// grab instants.
|
||||
fn grab_saturated(
|
||||
b: &mut PaceBudget,
|
||||
start: std::time::Instant,
|
||||
interval: std::time::Duration,
|
||||
n: usize,
|
||||
) -> Vec<std::time::Instant> {
|
||||
let mut now = start;
|
||||
let mut grabs = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let gate = b.earliest(now, interval);
|
||||
let grab = gate.max(now);
|
||||
b.charge();
|
||||
grabs.push(grab);
|
||||
now = grab;
|
||||
}
|
||||
grabs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_pins_a_saturated_source_at_the_interval() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 120);
|
||||
// Whatever the initial credit bought, the total may exceed the on-rate schedule by at
|
||||
// most the burst cap — 120 grabs span no less than (120 - 1 - CAP) intervals.
|
||||
let span = grabs[119].duration_since(grabs[0]);
|
||||
assert!(
|
||||
span >= interval.mul_f32(120.0 - 1.0 - PaceBudget::CAP),
|
||||
"span {span:?} admits more than CAP frames of overshoot"
|
||||
);
|
||||
// And the steady state is EXACTLY the interval: past the warmup, consecutive grabs are
|
||||
// one interval apart (not 0.9 — the 132-fps bug).
|
||||
for w in grabs[20..].windows(2) {
|
||||
let gap = w[1].duration_since(w[0]);
|
||||
assert!(
|
||||
gap >= interval.mul_f32(0.999) && gap <= interval.mul_f32(1.001),
|
||||
"steady-state gap {gap:?} != interval {interval:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_never_delays_an_on_rate_or_slow_source() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// A source at half the pacing rate (a 60 fps game on a 120 fps session): every arrival
|
||||
// banks two frames of credit and spends one — the gate is always already open.
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval * 2;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"slow source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
// Exactly on-rate: still never gated (credit hovers at the cap, never below 1).
|
||||
let mut b = PaceBudget::new(t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..50 {
|
||||
now += interval;
|
||||
assert_eq!(
|
||||
b.earliest(now, interval),
|
||||
now,
|
||||
"on-rate source must not be gated"
|
||||
);
|
||||
b.charge();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pace_budget_burst_after_a_stall_is_capped() {
|
||||
let interval = std::time::Duration::from_millis(10);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut b = PaceBudget::new(t0);
|
||||
// Settle into the gated steady state, then stall the source for 10 intervals.
|
||||
let grabs = grab_saturated(&mut b, t0, interval, 20);
|
||||
let stall_end = grabs[19] + interval * 10;
|
||||
// However long the stall, the recovery may run ahead of the on-rate schedule by at most
|
||||
// CAP frames: the second post-stall grab is already re-gated.
|
||||
let after = grab_saturated(&mut b, stall_end, interval, 3);
|
||||
assert_eq!(after[0], stall_end, "first post-stall grab is immediate");
|
||||
assert!(
|
||||
after[1].duration_since(after[0]) >= interval.mul_f32(2.0 - PaceBudget::CAP),
|
||||
"second post-stall grab spent more than the burst cap"
|
||||
);
|
||||
assert!(
|
||||
after[2].duration_since(after[1]) >= interval.mul_f32(0.999),
|
||||
"third post-stall grab must be back on the interval grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_mode_multiplier_scales_only_the_refresh() {
|
||||
// Default (no env set in the test process) is 1× — the identity, which is what every
|
||||
|
||||
@@ -118,7 +118,6 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@ mod linux {
|
||||
false,
|
||||
policy,
|
||||
vout.expect_exact_dims,
|
||||
compositor == pf_vdisplay::Compositor::Kwin,
|
||||
)
|
||||
.context("attach the PipeWire capturer")?;
|
||||
cap.set_active(true);
|
||||
|
||||
Reference in New Issue
Block a user