Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 61547b512a fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 2m20s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m6s
android / android (pull_request) Successful in 3m43s
ci / web (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust (pull_request) Successful in 6m30s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:54:07 +02:00
enricobuehlerandClaude Opus 5 166c93c079 fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
android / android (pull_request) Canceled after 1m21s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 1m32s
ci / rust-arm64 (pull_request) Canceled after 1m28s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 1m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:52:39 +02:00
enricobuehlerandClaude Opus 5 8749bd1396 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
ci / web (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m42s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
android / android (pull_request) Successful in 2m50s
ci / docs-site (pull_request) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 12m58s
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:35:43 +02:00
11 changed files with 24 additions and 444 deletions
@@ -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 falsetrue 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
// falsetrue 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
+1 -6
View File
@@ -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>)
}
+1 -14
View File
@@ -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,
)?
+1 -2
View File
@@ -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 {
+8 -75
View File
@@ -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]
-10
View File
@@ -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")
}
-1
View File
@@ -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);
+10 -186
View File
@@ -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
-1
View File
@@ -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")?
}
-1
View File
@@ -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);