Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca192b9ab | ||
|
|
022ede651f | ||
|
|
21f43d7f48 | ||
|
|
8d1e5ab5dd | ||
|
|
9e28cd101c |
@@ -42,6 +42,56 @@ availability probe. The `comm` fast path is still one read for every ordinary di
|
||||
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
|
||||
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
|
||||
|
||||
### Game Mode on Nobara — the WSI opt-out never reached the games
|
||||
|
||||
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
|
||||
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
|
||||
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
|
||||
input over a black picture, with no error on either side.
|
||||
|
||||
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
|
||||
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
|
||||
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
|
||||
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
|
||||
near the top of the script — before it launches anything — so the opt-out survived exactly as long
|
||||
as it took the script to start, and every process the session spawned got the layer back. Nothing
|
||||
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
|
||||
|
||||
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
|
||||
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
|
||||
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
|
||||
session script never mentions that second variable, so it is the one that survives. Both spellings
|
||||
go out, on the transient unit and on the box's own session drop-in.
|
||||
|
||||
### punktfunk-gamescope `+pfhdr6` — a NO_FOCUS window can no longer steal the composite
|
||||
|
||||
🛑 **A mapped-but-unpainted window carrying `GAMESCOPE_NO_FOCUS=1` could win gamescope's focus
|
||||
selection and turn the composite — and the stream fed from it — black while every health signal
|
||||
stayed green.** Bazzite's hhd-ui (Handheld Daemon overlay) sets that atom once at init, stamps
|
||||
Steam's appid, and crash-loops under a headless takeover; each respawn remapped a fullscreen black
|
||||
window that steamcompmgr then chose over Big Picture (observed on a Bazzite box: client stats
|
||||
happily decoding 60 fps at 0.1 Mb/s of black; killing hhd-ui restored the picture instantly). No
|
||||
gamescope — upstream or Bazzite's fork — ever consumed the atom; its setters (hhd-ui, MangoHud)
|
||||
show and hide via the `STEAM_OVERLAY` protocol and rely on never being focusable. Patch 0008 wires
|
||||
`GAMESCOPE_NO_FOCUS` exactly like `GAMESCOPE_EXTERNAL_OVERLAY` (read at map, PropertyNotify-tracked,
|
||||
skipped by both focus-candidate collectors) without touching compositing or `appID`. Banner
|
||||
`+pfhdr5` → `+pfhdr6`; no new capability — the bump is so a field box's banner tells the two
|
||||
behaviors apart.
|
||||
|
||||
### Linux capture — the truncated first attempt no longer latches sticky downgrades
|
||||
|
||||
🛑 **The pipeline retry loop's deliberately short (2.5 s) first-frame attempt could permanently
|
||||
downgrade the whole host process.** On expiry, the portal capturer's timeout diagnosis latched
|
||||
whichever offer it implicated — HDR capture off (per source), the raw-dmabuf offer off, the
|
||||
EGL→CUDA offer off — as if the compositor had refused it, when the budget was truncated by design
|
||||
and a gamescope cold start routinely needs longer before delivering anything. One lost race at
|
||||
connect then pinned every later session to SDR and/or CPU capture until the host restarted. The
|
||||
truncated attempt is now declared provisional end to end
|
||||
(`Capturer::next_frame_within_provisional`): its expiry names the same suspect in the error text
|
||||
but latches nothing; only the full-length attempts that follow hand down negotiation verdicts. The
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
@@ -43,6 +43,21 @@ pub trait Capturer: Send {
|
||||
self.next_frame()
|
||||
}
|
||||
|
||||
/// [`next_frame_within`](Self::next_frame_within), but the caller declares the budget
|
||||
/// PROVISIONAL: its expiry is the retry schedule firing (the deliberately truncated first
|
||||
/// attempt), not a verdict on anything this capture offered. The portal backend must NOT
|
||||
/// latch its sticky process-wide downgrades (HDR capture, either dmabuf-only offer) from a
|
||||
/// provisional expiry — a gamescope cold start routinely outlives the short window while it
|
||||
/// would have accepted every offer, and one latched race used to pin the whole host process
|
||||
/// to SDR/CPU capture. The full-length attempt that follows delivers the honest verdict.
|
||||
/// Backends that latch nothing from a timeout just delegate.
|
||||
fn next_frame_within_provisional(
|
||||
&mut self,
|
||||
budget: std::time::Duration,
|
||||
) -> Result<CapturedFrame> {
|
||||
self.next_frame_within(budget)
|
||||
}
|
||||
|
||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||
|
||||
@@ -533,7 +533,7 @@ fn spawn_pipewire(
|
||||
|
||||
impl Capturer for PortalCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
self.frame_within(Duration::from_secs(10))
|
||||
self.frame_within(Duration::from_secs(10), TimeoutVerdict::Conclusive)
|
||||
}
|
||||
|
||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
@@ -563,7 +563,13 @@ impl Capturer for PortalCapturer {
|
||||
}
|
||||
|
||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
self.frame_within(budget)
|
||||
self.frame_within(budget, TimeoutVerdict::Conclusive)
|
||||
}
|
||||
|
||||
fn next_frame_within_provisional(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
// The retry loop's truncated first attempt: its expiry re-runs the schedule, it does not
|
||||
// convict an offer — see `TimeoutVerdict` and the latch arms in `next_frame_timed_out`.
|
||||
self.frame_within(budget, TimeoutVerdict::Provisional)
|
||||
}
|
||||
|
||||
fn supports_arrival_wait(&self) -> bool {
|
||||
@@ -699,12 +705,73 @@ impl Capturer for PortalCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an expired first-frame budget is allowed to CONVICT an offer. The retry loop's
|
||||
/// deliberately truncated first attempt passes `Provisional`: its expiry means the schedule
|
||||
/// moved on, not that the compositor refused anything — a gamescope cold start regularly needs
|
||||
/// longer than that window to accept every offer it would have accepted. Latching from it pinned
|
||||
/// the whole host process to SDR + CPU capture off a race the attempt lost by design; only a
|
||||
/// full-length wait carries a verdict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TimeoutVerdict {
|
||||
Conclusive,
|
||||
Provisional,
|
||||
}
|
||||
|
||||
/// Which offer a first-frame timeout implicates — the diagnosis behind
|
||||
/// [`PortalCapturer::next_frame_timed_out`], split out pure so the latch policy is testable.
|
||||
/// Mirrors the negotiation state exactly: a negotiated format clears every offer (the compositor
|
||||
/// accepted, it just produced nothing), and a forced `PUNKTFUNK_ZEROCOPY=1` keeps both dmabuf
|
||||
/// arms erroring loudly instead of implicating them (the operator asked for exactly that path).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TimeoutOffer {
|
||||
/// Format negotiated; no offer implicated — the compositor produced no buffers.
|
||||
NoBuffers,
|
||||
/// The 10-bit PQ/BT.2020 (HDR) dmabuf offer was never accepted.
|
||||
Hdr,
|
||||
/// The dmabuf-only raw-passthrough offer was never accepted.
|
||||
RawDmabuf,
|
||||
/// The dmabuf-only EGL→CUDA offer was never accepted.
|
||||
GpuDmabuf,
|
||||
/// Nothing negotiated and no offer implicated — format/modifier mismatch.
|
||||
NoFormat,
|
||||
}
|
||||
|
||||
fn classify_first_frame_timeout(
|
||||
negotiated: bool,
|
||||
hdr_offer: bool,
|
||||
vaapi_dmabuf: bool,
|
||||
gpu_dmabuf_offer: bool,
|
||||
zerocopy_forced: bool,
|
||||
) -> TimeoutOffer {
|
||||
if negotiated {
|
||||
TimeoutOffer::NoBuffers
|
||||
} else if hdr_offer {
|
||||
TimeoutOffer::Hdr
|
||||
} else if vaapi_dmabuf && !zerocopy_forced {
|
||||
TimeoutOffer::RawDmabuf
|
||||
} else if gpu_dmabuf_offer && !zerocopy_forced {
|
||||
TimeoutOffer::GpuDmabuf
|
||||
} else {
|
||||
TimeoutOffer::NoFormat
|
||||
}
|
||||
}
|
||||
|
||||
/// The latch policy: only a conclusive expiry of an offer-implicating timeout fires the offer's
|
||||
/// sticky process-wide downgrade.
|
||||
fn timeout_convicts(offer: TimeoutOffer, verdict: TimeoutVerdict) -> bool {
|
||||
verdict == TimeoutVerdict::Conclusive
|
||||
&& matches!(
|
||||
offer,
|
||||
TimeoutOffer::Hdr | TimeoutOffer::RawDmabuf | TimeoutOffer::GpuDmabuf
|
||||
)
|
||||
}
|
||||
|
||||
impl PortalCapturer {
|
||||
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
||||
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
||||
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
||||
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
||||
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
fn frame_within(&mut self, budget: Duration, verdict: TimeoutVerdict) -> Result<CapturedFrame> {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
loop {
|
||||
if self.signals.broken.load(Ordering::Relaxed) {
|
||||
@@ -730,7 +797,7 @@ impl PortalCapturer {
|
||||
if let Some(f) = self.take_frame() {
|
||||
return Ok(f);
|
||||
}
|
||||
return self.next_frame_timed_out(e, budget);
|
||||
return self.next_frame_timed_out(e, budget, verdict);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -752,83 +819,118 @@ impl PortalCapturer {
|
||||
}
|
||||
|
||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
/// into the diagnosis-bearing error, and fire the offer's sticky downgrade latch when — and
|
||||
/// only when — the expiry convicts the offer (see [`timeout_convicts`]).
|
||||
fn next_frame_timed_out(
|
||||
&self,
|
||||
err: RecvTimeoutError,
|
||||
budget: Duration,
|
||||
verdict: TimeoutVerdict,
|
||||
) -> Result<CapturedFrame> {
|
||||
let within = budget.as_secs_f32();
|
||||
match err {
|
||||
RecvTimeoutError::Timeout => {
|
||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||
// just a symptom: did the format negotiate (compositor produced no buffers) or
|
||||
// not (no acceptable format / node never emitted a param)?
|
||||
if self.signals.negotiated.load(Ordering::Relaxed) {
|
||||
Err(anyhow!(
|
||||
let offer = classify_first_frame_timeout(
|
||||
self.signals.negotiated.load(Ordering::Relaxed),
|
||||
self.hdr_offer,
|
||||
self.vaapi_dmabuf,
|
||||
self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed),
|
||||
pf_zerocopy::zerocopy_forced(),
|
||||
);
|
||||
let convicted = timeout_convicts(offer, verdict);
|
||||
// A provisional expiry names the same suspect but hands down no sentence — the
|
||||
// full-length retry that follows is the one whose timeout latches.
|
||||
let sentence = if convicted {
|
||||
"" // each arm below states its own downgrade
|
||||
} else {
|
||||
" (short first-attempt window — nothing is latched; the full-length retry \
|
||||
decides)"
|
||||
};
|
||||
match offer {
|
||||
TimeoutOffer::NoBuffers => Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
||||
buffers arrived — the compositor produced no frames (virtual output \
|
||||
idle/unmapped, capture never started, or a stream bound during a \
|
||||
compositor (re)start that will never deliver — a reconnect fixes that)",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.hdr_offer {
|
||||
// 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.
|
||||
super::note_hdr_capture_failed(self.hdr_source);
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
||||
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
||||
reconnect to stream SDR",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
|
||||
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
|
||||
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
|
||||
// instead of failing this same negotiation forever. The latch is SCOPED to the
|
||||
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
|
||||
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
|
||||
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
|
||||
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
|
||||
// enough to do that.
|
||||
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the dmabuf-only offer (raw-dmabuf passthrough) — downgrading \
|
||||
THIS path to CPU capture for the rest of the process; the pipeline \
|
||||
rebuild will renegotiate without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
|
||||
&& !pf_zerocopy::zerocopy_forced()
|
||||
{
|
||||
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
|
||||
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
|
||||
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
|
||||
// a compositor that allocates none of the importer's modifiers refuses them
|
||||
// identically on every retry, so latch the offer off and let the pipeline
|
||||
// rebuild renegotiate the CPU path instead of re-running this same 10 s
|
||||
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
|
||||
// loudly instead (same rule as the raw arm).
|
||||
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the dmabuf-only offer (EGL→CUDA GPU import) — downgrading THIS \
|
||||
offer to the CPU path for the rest of the process; the pipeline rebuild \
|
||||
will renegotiate without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
)),
|
||||
TimeoutOffer::Hdr => {
|
||||
// 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 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.
|
||||
if convicted {
|
||||
super::note_hdr_capture_failed(self.hdr_source);
|
||||
}
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
||||
monitor in HDR mode on GNOME 50+?{}",
|
||||
self.node_id,
|
||||
if convicted {
|
||||
" Downgrading this host to SDR capture; reconnect to stream SDR"
|
||||
} else {
|
||||
sentence
|
||||
}
|
||||
))
|
||||
}
|
||||
TimeoutOffer::RawDmabuf => {
|
||||
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
|
||||
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
|
||||
// instead of failing this same negotiation forever. The latch is SCOPED to the
|
||||
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
|
||||
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
|
||||
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
|
||||
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
|
||||
// enough to do that.
|
||||
if convicted {
|
||||
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
|
||||
}
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the dmabuf-only offer (raw-dmabuf passthrough){}",
|
||||
self.node_id,
|
||||
if convicted {
|
||||
" — downgrading THIS path to CPU capture for the rest of the \
|
||||
process; the pipeline rebuild will renegotiate without dmabuf"
|
||||
} else {
|
||||
sentence
|
||||
}
|
||||
))
|
||||
}
|
||||
TimeoutOffer::GpuDmabuf => {
|
||||
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
|
||||
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
|
||||
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One FULL-LENGTH timeout
|
||||
// is conclusive: a compositor that allocates none of the importer's
|
||||
// modifiers refuses them identically on every retry, so latch the offer off
|
||||
// and let the pipeline rebuild renegotiate the CPU path instead of
|
||||
// re-running this same 10 s timeout on every reconnect. A forced
|
||||
// PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead (same rule as the raw
|
||||
// arm).
|
||||
if convicted {
|
||||
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
|
||||
}
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the dmabuf-only offer (EGL→CUDA GPU import){}",
|
||||
self.node_id,
|
||||
if convicted {
|
||||
" — downgrading THIS offer to the CPU path for the rest of the \
|
||||
process; the pipeline rebuild will renegotiate without dmabuf"
|
||||
} else {
|
||||
sentence
|
||||
}
|
||||
))
|
||||
}
|
||||
TimeoutOffer::NoFormat => Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
||||
completed — the compositor offered no format this consumer accepts \
|
||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||
self.node_id
|
||||
))
|
||||
)),
|
||||
}
|
||||
}
|
||||
RecvTimeoutError::Disconnected => Err(anyhow!(
|
||||
@@ -874,3 +976,89 @@ mod pipewire;
|
||||
// unit-test without a compositor, which is the point.
|
||||
mod pw_cursor;
|
||||
mod pw_pods;
|
||||
|
||||
#[cfg(test)]
|
||||
mod first_frame_timeout_tests {
|
||||
use super::{classify_first_frame_timeout, timeout_convicts, TimeoutOffer, TimeoutVerdict};
|
||||
|
||||
#[test]
|
||||
fn a_provisional_expiry_convicts_no_offer_whatever_was_on_the_table() {
|
||||
// The bug this pins down: the retry loop's truncated 2.5 s first attempt latched all
|
||||
// three sticky process-wide downgrades as if the compositor had refused the offers — a
|
||||
// gamescope HDR cold start then streamed SDR (and CPU-copied) for the process lifetime.
|
||||
for offer in [
|
||||
TimeoutOffer::NoBuffers,
|
||||
TimeoutOffer::Hdr,
|
||||
TimeoutOffer::RawDmabuf,
|
||||
TimeoutOffer::GpuDmabuf,
|
||||
TimeoutOffer::NoFormat,
|
||||
] {
|
||||
assert!(
|
||||
!timeout_convicts(offer, TimeoutVerdict::Provisional),
|
||||
"provisional expiry must not latch {offer:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conclusive_expiry_convicts_exactly_the_offer_bearing_diagnoses() {
|
||||
assert!(timeout_convicts(
|
||||
TimeoutOffer::Hdr,
|
||||
TimeoutVerdict::Conclusive
|
||||
));
|
||||
assert!(timeout_convicts(
|
||||
TimeoutOffer::RawDmabuf,
|
||||
TimeoutVerdict::Conclusive
|
||||
));
|
||||
assert!(timeout_convicts(
|
||||
TimeoutOffer::GpuDmabuf,
|
||||
TimeoutVerdict::Conclusive
|
||||
));
|
||||
// A negotiated-but-idle stream and a plain format mismatch implicate no offer — nothing
|
||||
// to latch even on a full-length wait.
|
||||
assert!(!timeout_convicts(
|
||||
TimeoutOffer::NoBuffers,
|
||||
TimeoutVerdict::Conclusive
|
||||
));
|
||||
assert!(!timeout_convicts(
|
||||
TimeoutOffer::NoFormat,
|
||||
TimeoutVerdict::Conclusive
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_mirrors_the_negotiation_state_precedence() {
|
||||
// A negotiated format clears every offer, whatever else was on the table.
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(true, true, true, true, false),
|
||||
TimeoutOffer::NoBuffers
|
||||
);
|
||||
// The HDR offer outranks the dmabuf arms (it is the offer that failed to negotiate).
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(false, true, true, true, false),
|
||||
TimeoutOffer::Hdr
|
||||
);
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(false, false, true, true, false),
|
||||
TimeoutOffer::RawDmabuf
|
||||
);
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(false, false, false, true, false),
|
||||
TimeoutOffer::GpuDmabuf
|
||||
);
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(false, false, false, false, false),
|
||||
TimeoutOffer::NoFormat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_zerocopy_keeps_both_dmabuf_arms_erroring_loudly_instead_of_implicated() {
|
||||
// PUNKTFUNK_ZEROCOPY=1 is the operator insisting on the path — the timeout falls through
|
||||
// to the generic diagnosis (and so never latches), exactly as the old else-if chain did.
|
||||
assert_eq!(
|
||||
classify_first_frame_timeout(false, false, true, true, true),
|
||||
TimeoutOffer::NoFormat
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,7 +1032,7 @@ fn write_session_plus_dropin(
|
||||
wsi = if wsi_ok {
|
||||
String::new()
|
||||
} else {
|
||||
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
|
||||
wsi_off_unit_lines()
|
||||
},
|
||||
);
|
||||
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
|
||||
@@ -3521,6 +3521,52 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
|
||||
Some(bind)
|
||||
}
|
||||
|
||||
/// The environment that turns the distro's `VkLayer_FROG_gamescope_wsi` off for a whole session
|
||||
/// tree — applied wherever we start one: [`launch_session`]'s transient unit and the box-session
|
||||
/// drop-in ([`write_session_plus_dropin`]).
|
||||
///
|
||||
/// ⭐⭐⭐ **`DISABLE_GAMESCOPE_WSI` is the one that actually works, and it is not the obvious one.**
|
||||
/// `gamescope-session-plus` does an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of
|
||||
/// the script, before it launches anything — so a `--setenv=ENABLE_GAMESCOPE_WSI=0` on the unit is
|
||||
/// CLOBBERED for the script and every child it spawns: gamescope, Steam, and every game Steam
|
||||
/// launches. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order
|
||||
/// (`loader.c`, `loader_implicit_layer_is_enabled`): `enable_environment` switches the layer on
|
||||
/// only when the variable equals exactly `"1"`, and then `disable_environment` is consulted last —
|
||||
/// *"has priority over everything else"* — where the mere PRESENCE of the variable, at any value,
|
||||
/// forces the layer off. Nothing in the session script mentions `DISABLE_GAMESCOPE_WSI`, so it is
|
||||
/// the only one of the two that survives the script.
|
||||
///
|
||||
/// ⚠️⚠️ What getting this wrong looks like in the field (Nobara 44, 2026-08-11): the layer stayed
|
||||
/// on for games while this host's own log said it had been disabled. Neither Steam Big Picture nor
|
||||
/// mangoapp is a Vulkan client, so both paint regardless and the session looks perfectly healthy —
|
||||
/// right up until a game starts. Then the game runs with sound and input while its swapchain is
|
||||
/// dead: a black screen, and not one line of error anywhere.
|
||||
///
|
||||
/// `ENABLE_GAMESCOPE_WSI=0` is kept alongside for a layer built without a `disable_environment`
|
||||
/// (the loader warns about such a layer but honours its `enable_environment`), and because it is
|
||||
/// what an operator reading the unit will look for.
|
||||
const WSI_OFF_ENV: [(&str, &str); 2] = [
|
||||
("DISABLE_GAMESCOPE_WSI", "1"),
|
||||
("ENABLE_GAMESCOPE_WSI", "0"),
|
||||
];
|
||||
|
||||
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
|
||||
fn wsi_off_setenv_args() -> Vec<String> {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("--setenv={name}={value}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
|
||||
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
|
||||
fn wsi_off_unit_lines() -> String {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("Environment={name}={value}\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
|
||||
///
|
||||
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
|
||||
@@ -3533,8 +3579,10 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
|
||||
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
|
||||
/// bump — which is why the check is "do the version triples differ", not a floor.
|
||||
///
|
||||
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
|
||||
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
|
||||
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
|
||||
/// metadata) — far cheaper than a client that cannot start.
|
||||
///
|
||||
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
|
||||
fn wsi_layer_matches_our_gamescope() -> bool {
|
||||
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
|
||||
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
|
||||
@@ -3589,14 +3637,17 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
// an armed bind is the one thing here that can stop the session coming up at all.
|
||||
let mut bind = arm_session_bind(&wrapper);
|
||||
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
|
||||
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
|
||||
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
|
||||
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
|
||||
let wsi_ok = wsi_layer_matches_our_gamescope();
|
||||
if !wsi_ok {
|
||||
tracing::warn!(
|
||||
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
|
||||
than the one we run — disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
|
||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
|
||||
which shows up as a black screen with no other symptom."
|
||||
than the one we run — disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
|
||||
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
|
||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
|
||||
Steam's own UI is not one, so what you see is a game that runs with sound and input \
|
||||
on a black screen, with no other symptom."
|
||||
);
|
||||
}
|
||||
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
|
||||
@@ -3606,7 +3657,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
cmd.arg(arg);
|
||||
}
|
||||
if !wsi_ok {
|
||||
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
|
||||
for arg in wsi_off_setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
}
|
||||
let status = cmd
|
||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
||||
@@ -4043,9 +4096,9 @@ mod tests {
|
||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
||||
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
|
||||
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
|
||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, wsi_off_setenv_args,
|
||||
wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
|
||||
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
|
||||
@@ -4706,4 +4759,33 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// `gamescope-session-plus` runs `export ENABLE_GAMESCOPE_WSI=1` before it launches anything,
|
||||
/// so that variable alone cannot turn the layer off for Steam or for the games Steam launches
|
||||
/// — only `DISABLE_GAMESCOPE_WSI`, which the script never mentions and which the Vulkan loader
|
||||
/// treats as an unconditional force-off, survives. Dropping it would restore the field bug
|
||||
/// (a game with sound and input on a black screen) while the host's log still claimed the
|
||||
/// layer was disabled, which is what made it so expensive to find. See [`WSI_OFF_ENV`].
|
||||
#[test]
|
||||
fn the_wsi_opt_out_carries_the_variable_the_session_script_cannot_clobber() {
|
||||
assert!(
|
||||
WSI_OFF_ENV.contains(&("DISABLE_GAMESCOPE_WSI", "1")),
|
||||
"the clobber-proof variable is the whole point of the opt-out"
|
||||
);
|
||||
|
||||
// Both spellings reach both launch paths, and neither may lose the other.
|
||||
let args = wsi_off_setenv_args();
|
||||
let lines = wsi_off_unit_lines();
|
||||
for (name, value) in WSI_OFF_ENV {
|
||||
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
|
||||
assert!(
|
||||
lines.contains(&format!("Environment={name}={value}\n")),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
|
||||
// Trailing newline: the drop-in body appends nothing after this block today, but the bind
|
||||
// lines above it rely on the same contract and the order has changed before.
|
||||
assert!(lines.ends_with('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4129,7 +4129,10 @@ fn build_pipeline_with_retry(
|
||||
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
|
||||
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
|
||||
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
|
||||
// 10 s window on every later attempt.
|
||||
// 10 s window on every later attempt. The truncated attempt is PROVISIONAL end to end: its
|
||||
// expiry must not latch the capturer's sticky downgrades (see
|
||||
// `Capturer::next_frame_within_provisional`) — only the full-length attempts hand down
|
||||
// negotiation verdicts.
|
||||
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=max_attempts {
|
||||
@@ -4484,7 +4487,13 @@ fn build_pipeline(
|
||||
}
|
||||
capturer.set_active(true);
|
||||
let first = match first_frame_budget {
|
||||
Some(budget) => capturer.next_frame_within(budget),
|
||||
// Provisional: this is the retry loop's deliberately truncated first attempt, and its
|
||||
// expiry is the schedule firing, not a negotiation verdict — the capturer must not latch
|
||||
// its sticky process-wide downgrades (HDR capture, the dmabuf-only offers) from it. A
|
||||
// gamescope cold start regularly outlives this window and then accepts every offer on the
|
||||
// full-length attempt that follows (observed on .41: one truncated expiry pinned the whole
|
||||
// host process to SDR + CPU capture).
|
||||
Some(budget) => capturer.next_frame_within_provisional(budget),
|
||||
None => capturer.next_frame(),
|
||||
};
|
||||
let frame = match first.context("first frame") {
|
||||
|
||||
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
|
||||
# bump it with the marker so pacman sees a new version when only our patches moved.
|
||||
_gsver=3.16.25
|
||||
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
|
||||
pkgver="${_gsver}.pfhdr5"
|
||||
pkgver="${_gsver}.pfhdr6"
|
||||
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
|
||||
# `.pfhdrN` level deliberately stays put — see README.md.
|
||||
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
|
||||
@@ -33,6 +33,12 @@ pkgver="${_gsver}.pfhdr5"
|
||||
# a session on every capture renegotiation — i.e. on every client connect, since the host sets the
|
||||
# session to the client's mode. This one DOES move `.pfhdrN`, even though it adds no capability:
|
||||
# every deployed pfhdr4 binary crash-loops, so an operator has to be able to tell them apart.
|
||||
#
|
||||
# pfhdr6 / rel 1: patch 0008 honors GAMESCOPE_NO_FOCUS — a mapped-but-unpainted window carrying it
|
||||
# (Bazzite's hhd-ui crash-looping under a headless takeover) used to WIN focus selection and turn
|
||||
# the composite (and the stream) black while every health signal stayed green. No capability the
|
||||
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
|
||||
# this way from one that cannot.
|
||||
pkgrel=1
|
||||
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -18,6 +18,7 @@ The patches here add the missing half, and nothing else. See
|
||||
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
|
||||
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
|
||||
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
|
||||
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
|
||||
|
||||
### Why the headless patch matters
|
||||
|
||||
@@ -84,14 +85,18 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|
||||
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
|
||||
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
|
||||
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
|
||||
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
|
||||
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
|
||||
|
||||
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
||||
|
||||
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
|
||||
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
|
||||
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
|
||||
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
|
||||
and strand hosts that gate on it.
|
||||
A patch that only fixes a crash does **not** automatically bump it: `0006` (the exit-time Vulkan
|
||||
teardown fix) changes nothing the host probes for, so it shipped as a `pkgrel` bump at `+pfhdr4` —
|
||||
exactly the split the PKGBUILD's own comment describes. Since every host probe is `>=`, a bump for
|
||||
a bugfix is safe but must earn its place: `0007` and `0008` moved the level anyway because their
|
||||
absence is invisible until a stream fails (a crash-loop per connect; a composite lost to a
|
||||
NO_FOCUS window), so field triage has to be able to read the difference off a box's banner.
|
||||
Bumping without either reason would advertise a capability tier that does not exist.
|
||||
|
||||
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
|
||||
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: enricobuehler <enrico.buehler@unom.io>
|
||||
Date: Tue, 11 Aug 2026 21:48:29 +0200
|
||||
Subject: [PATCH] =?UTF-8?q?steamcompmgr:=20honor=20GAMESCOPE=5FNO=5FFOCUS?=
|
||||
=?UTF-8?q?=20=E2=80=94=20such=20windows=20are=20never=20focus=20candidate?=
|
||||
=?UTF-8?q?s?=
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
hhd (Handheld Daemon) sets GAMESCOPE_NO_FOCUS=1 on its hhd-ui overlay window once at init and
|
||||
never clears it (hhd src/hhd/plugins/overlay/x11.py, prepare_hhd); MangoHud sets the same atom.
|
||||
The show/hide protocol for these clients is STEAM_OVERLAY / STEAM_INPUT_FOCUS — the window is
|
||||
never meant to win focus selection on its own.
|
||||
|
||||
Nothing consumed the atom: neither this tree nor Bazzite's fork (checked ba148) interns it, so a
|
||||
mapped-but-unpainted hhd-ui window — it crash-loops under a headless punktfunk takeover and
|
||||
remaps on every respawn, stamping Steam's appid 769 — was a perfectly ordinary focus candidate.
|
||||
steamcompmgr picked it over Big Picture, and the composite (and the stream fed from it) went
|
||||
black while every other health signal stayed green (observed on Bazzite .41, 2026-08-11:
|
||||
GAMESCOPE_FOCUSED_WINDOW = the hhd-ui window, GAMESCOPE_NO_FOCUS(CARDINAL)=1 on that window,
|
||||
client stats 60 fps at 0.1 Mb/s of black; killing hhd-ui flipped focus back to Steam and the
|
||||
picture returned instantly).
|
||||
|
||||
Wire the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map, tracked on PropertyNotify
|
||||
(with MakeFocusDirty), skipped in both focus-candidate collectors (X11 and XDG). Unlike the
|
||||
overlay flags it does NOT zero appID and does not change compositing: the window still paints
|
||||
normally if something else (the baselayer protocol) brings it into view; it is only barred from
|
||||
being CHOSEN.
|
||||
|
||||
Banner: +pfhdr6 (no new capability — the bump exists so a field box's banner distinguishes a
|
||||
build that can lose its composite to a NO_FOCUS window from one that cannot).
|
||||
---
|
||||
src/meson.build | 3 ++-
|
||||
src/steamcompmgr.cpp | 24 ++++++++++++++++++++----
|
||||
src/steamcompmgr_shared.hpp | 4 ++++
|
||||
src/xwayland_ctx.hpp | 1 +
|
||||
4 files changed, 27 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/src/meson.build b/src/meson.build
|
||||
index 9a3a287..acfcaea 100644
|
||||
--- a/src/meson.build
|
||||
+++ b/src/meson.build
|
||||
@@ -185,7 +185,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
||||
# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
|
||||
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
|
||||
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
|
||||
-version_tag = vcs_tag + '+pfhdr5' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
+# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
|
||||
+version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
|
||||
gamescope_version_conf = configuration_data()
|
||||
gamescope_version_conf.set('VCS_TAG', version_tag)
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index 64e1a8c..14596ae 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -1109,6 +1109,7 @@ bool g_bPendingFade = false;
|
||||
#define STEAM_PROP "STEAM_BIGPICTURE"
|
||||
#define OVERLAY_PROP "STEAM_OVERLAY"
|
||||
#define EXTERNAL_OVERLAY_PROP "GAMESCOPE_EXTERNAL_OVERLAY"
|
||||
+#define NO_FOCUS_PROP "GAMESCOPE_NO_FOCUS"
|
||||
#define GAMES_RUNNING_PROP "STEAM_GAMES_RUNNING"
|
||||
#define SCREEN_SCALE_PROP "STEAM_SCREEN_SCALE"
|
||||
#define SCREEN_MAGNIFICATION_PROP "STEAM_SCREEN_MAGNIFICATION"
|
||||
@@ -3848,8 +3849,8 @@ found:;
|
||||
|
||||
for (steamcompmgr_win_t *w = this->list; w; w = w->xwayland().next)
|
||||
{
|
||||
- // Always skip system tray icons and overlays
|
||||
- if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay )
|
||||
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
|
||||
+ if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay || w->isNoFocus )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4197,8 +4198,8 @@ steamcompmgr_xdg_get_possible_focus_windows()
|
||||
std::vector< steamcompmgr_win_t* > windows;
|
||||
for ( auto &win : g_steamcompmgr_xdg_wins )
|
||||
{
|
||||
- // Always skip system tray icons and overlays
|
||||
- if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay )
|
||||
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
|
||||
+ if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay || win->isNoFocus )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4960,6 +4961,10 @@ map_win(xwayland_ctx_t* ctx, Window id, unsigned long sequence)
|
||||
if ( w->isExternalOverlay )
|
||||
w->appID = 0;
|
||||
|
||||
+ // Never a focus candidate; appID stays — the window may share the focused app's id (hhd-ui
|
||||
+ // stamps Steam's) and zeroing it here is not needed to keep it out of focus selection.
|
||||
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
|
||||
+
|
||||
w->oulTargetVROverlay = get_u64_prop(ctx, w->xwayland().id, ctx->atoms.steamGamescopeVROverlayTarget);
|
||||
if ( w->oulTargetVROverlay )
|
||||
{
|
||||
@@ -5243,6 +5248,7 @@ add_win(xwayland_ctx_t *ctx, Window id, Window prev, unsigned long sequence)
|
||||
|
||||
new_win->isOverlay = false;
|
||||
new_win->isExternalOverlay = false;
|
||||
+ new_win->isNoFocus = false;
|
||||
new_win->isSteamLegacyBigPicture = false;
|
||||
new_win->isSteamStreamingClient = false;
|
||||
new_win->isSteamStreamingClientVideo = false;
|
||||
@@ -6193,6 +6199,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev)
|
||||
MakeFocusDirty();
|
||||
}
|
||||
}
|
||||
+ if (ev->atom == ctx->atoms.noFocusAtom)
|
||||
+ {
|
||||
+ steamcompmgr_win_t * w = find_win(ctx, ev->window);
|
||||
+ if (w)
|
||||
+ {
|
||||
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
|
||||
+ MakeFocusDirty();
|
||||
+ }
|
||||
+ }
|
||||
if (ev->atom == ctx->atoms.winTypeAtom)
|
||||
{
|
||||
steamcompmgr_win_t * w = find_win(ctx, ev->window);
|
||||
@@ -7927,6 +7942,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_
|
||||
ctx->atoms.gameAtom = XInternAtom(ctx->dpy, GAME_PROP, false);
|
||||
ctx->atoms.overlayAtom = XInternAtom(ctx->dpy, OVERLAY_PROP, false);
|
||||
ctx->atoms.externalOverlayAtom = XInternAtom(ctx->dpy, EXTERNAL_OVERLAY_PROP, false);
|
||||
+ ctx->atoms.noFocusAtom = XInternAtom(ctx->dpy, NO_FOCUS_PROP, false);
|
||||
ctx->atoms.opacityAtom = XInternAtom(ctx->dpy, OPACITY_PROP, false);
|
||||
ctx->atoms.gamesRunningAtom = XInternAtom(ctx->dpy, GAMES_RUNNING_PROP, false);
|
||||
ctx->atoms.screenScaleAtom = XInternAtom(ctx->dpy, SCREEN_SCALE_PROP, false);
|
||||
diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp
|
||||
index 21ddc5f..924e0d2 100644
|
||||
--- a/src/steamcompmgr_shared.hpp
|
||||
+++ b/src/steamcompmgr_shared.hpp
|
||||
@@ -116,6 +116,10 @@ struct steamcompmgr_win_t {
|
||||
uint32_t appID = 0;
|
||||
bool isOverlay = false;
|
||||
bool isExternalOverlay = false;
|
||||
+ // GAMESCOPE_NO_FOCUS on the window: the client asks never to be a focus candidate (hhd-ui and
|
||||
+ // MangoHud set it once at init). Unlike an overlay it still composites normally if something
|
||||
+ // else focuses it into view; it is only excluded from focus selection.
|
||||
+ bool isNoFocus = false;
|
||||
|
||||
bool bIsSteamPid = false;
|
||||
bool bIsSteamWebHelperPid = false;
|
||||
diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp
|
||||
index 978728a..d6ce90f 100644
|
||||
--- a/src/xwayland_ctx.hpp
|
||||
+++ b/src/xwayland_ctx.hpp
|
||||
@@ -105,6 +105,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable
|
||||
Atom gameAtom;
|
||||
Atom overlayAtom;
|
||||
Atom externalOverlayAtom;
|
||||
+ Atom noFocusAtom;
|
||||
Atom gamesRunningAtom;
|
||||
Atom screenZoomAtom;
|
||||
Atom screenScaleAtom;
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
Reference in New Issue
Block a user