Compare commits

..
Author SHA1 Message Date
enricobuehler 6ca192b9ab fix(packaging/gamescope): +pfhdr6 — a GAMESCOPE_NO_FOCUS window can no longer steal the composite
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 1m35s
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 1m30s
ci / rust (pull_request) Successful in 4m52s
android / android (pull_request) Successful in 5m36s
Patch 0008: honor GAMESCOPE_NO_FOCUS in steamcompmgr's focus selection. hhd (Handheld Daemon)
sets the atom once at init on its hhd-ui overlay window and never clears it; MangoHud sets it
too; show/hide for these clients runs over the STEAM_OVERLAY protocol. NOTHING consumed the atom
— not upstream gamescope, not Bazzite's fork (checked ba148 by strings) — 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 an ordinary focus candidate, and steamcompmgr
picked it over Big Picture. The composite, and the stream fed from it, went black while every
health signal stayed green: on .41 the client sat decoding 60 fps at 0.1 Mb/s of black,
GAMESCOPE_FOCUSED_WINDOW named the hhd-ui window with GAMESCOPE_NO_FOCUS(CARDINAL)=1 on it, and
killing hhd-ui brought the picture back the same second.

The patch wires the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map,
PropertyNotify-tracked with MakeFocusDirty, skipped by both focus-candidate collectors (X11 and
XDG) — and touches neither compositing nor appID, so a NO_FOCUS window still paints if the
baselayer protocol brings it into view; it is only barred from being CHOSEN. Applies cleanly on
the full 0001..0008 series from the bare 5fb8dce4 pin (verified with git am).

Banner +pfhdr5 → +pfhdr6, PKGBUILD 3.16.25.pfhdr6-1; README gains the 0008 row, the missing
+pfhdr5 ledger row, and the reconciled bump rule (a bugfix bumps the level only when field triage
must read the difference off a box's banner — 0007's crash-loop, 0008's lost composite).
2026-08-11 22:07:24 +02:00
enricobuehler 022ede651f fix(pf-capture): the truncated first attempt no longer latches the sticky downgrades
The pipeline retry loop deliberately shortens its first attempt's first-frame wait to 2.5s so a
stream bound during a gamescope re-init fails over quickly. But the portal capturer's timeout
diagnosis treated EVERY expiry as a verdict: it latched whichever offer it implicated — HDR
capture off for the source, the raw-dmabuf offer off, the EGL→CUDA offer off — process-wide and
permanently, when the attempt was truncated by design and a gamescope cold start routinely
delivers nothing inside that window while accepting every offer a few seconds later (observed on
.41: pid 1962 hit the expiry at connect and every later session in that process ran silently
degraded). This is bug #6 from the pf-capture sweep, verified then and unfixed until now.

The truncated attempt is now declared PROVISIONAL end to end: a new
`Capturer::next_frame_within_provisional` (default: delegates) lets the retry loop say "this
budget is the schedule, not a verdict", and the portal capturer's timeout classification — split
out as the pure `classify_first_frame_timeout` + `timeout_convicts`, with tests — names the same
suspect in the error text but latches nothing unless the expired budget was full-length.
2026-08-11 22:06:31 +02:00
enricobuehler 21f43d7f48 Merge pull request 'Games ran black in Nobara's Game Mode — the session script re-exports the WSI variable #144 cleared' (#165) from worktree-nobara-wsi-layer-clobber into main
apple / swift (push) Successful in 1m39s
ci / bun-nix (push) Successful in 55s
ci / web (push) Successful in 1m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 2m22s
deb / build-publish-client-arm64 (push) Successful in 2m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
ci / rust (push) Successful in 4m57s
android / android (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 10m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
arch / build-publish (push) Successful in 9m36s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Failing after 2m39s
apple / screenshots (push) Successful in 6m30s
deb / build-publish (push) Successful in 7m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m35s
windows-host / package (push) Successful in 15m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
Reviewed-on: #165
2026-08-11 18:33:05 +00:00
enricobuehler 8d1e5ab5dd Merge pull request 'The Android console grows up — the desktop's real backdrop, Apple's drum, and the cross-client tables stop drifting in silence' (#163) from worktree-android-console-visual-refresh into main
audit / bun-audit (sdk) (push) Successful in 21s
audit / bun-audit (web) (push) Successful in 21s
audit / bun-audit (plugin-kit) (push) Successful in 42s
android / android (push) Canceled after 1m8s
arch / build-publish (push) Failing after 1m1s
apple / swift (push) Canceled after 1m23s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 47s
ci / docs-site (push) Canceled after 41s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 1m0s
deb / build-publish-host (push) Canceled after 1m0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 18s
docker / deploy-docs (push) Canceled after 0s
audit / docs-site-audit (push) Successful in 25s
audit / pnpm-audit (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
audit / cargo-audit (push) Successful in 47s
windows-host / package (push) Canceled after 1m57s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / license-gate (push) Successful in 5m32s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m16s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m46s
release / apple (push) Successful in 10m18s
flatpak / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m51s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m46s
nix / flake (push) Failing after 16m5s
Reviewed-on: #163
2026-08-11 18:32:00 +00:00
enricobuehler 9e28cd101c fix(pf-vdisplay): the WSI opt-out never reached the games — the session script re-exports it
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m27s
android / android (pull_request) Successful in 5m53s
ci / rust (pull_request) Successful in 11m10s
A Nobara 44 field box on 0.27.0 got a working Game Mode takeover — right mode,
right refresh, perf overlay in the stream — and then every game it launched
played sound and took input over a BLACK PICTURE, with no error anywhere.

#144 disabled the distro's `VkLayer_FROG_gamescope_wsi` with
`--setenv=ENABLE_GAMESCOPE_WSI=0` on the transient unit, because that layer
speaks the distro gamescope's `gamescope_swapchain` protocol and ours rejects
its `swapchain_feedback`, killing the client. `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 lived exactly as long as
it took the script to start, and gamescope, Steam and every game got the layer
back. The host logged that it had disabled it, which is what made this cost a
field round-trip: the claim was true of the unit and false of everything in it.

Nothing else looked wrong because the casualty is Vulkan clients specifically.
Steam's Big Picture UI is not one, so the session came up looking perfectly
healthy right up until a game started.

Send `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader resolves an implicit
layer's two manifest knobs in a fixed order (`loader_implicit_layer_is_enabled`):
`enable_environment` must equal `"1"` to switch the layer on, and
`disable_environment` is consulted last — "has priority over everything else" —
where the mere PRESENCE of the variable, at any value, forces it off. The
session script never mentions that one, so it is the only one that survives it.

Both spellings now go out through one place, so the transient unit
(`launch_session`) and the box's own session drop-in (`write_session_plus_dropin`)
cannot drift apart — the same shape `SessionBind` already uses for the bind.
`ENABLE_GAMESCOPE_WSI=0` stays alongside for a layer built without a
`disable_environment`, and because it is what an operator reads the unit for.
2026-08-11 20:14:19 +02:00
8 changed files with 598 additions and 83 deletions
+50
View File
@@ -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.
+15
View File
@@ -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
+252 -64
View File
@@ -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 (EGLCUDA 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 (EGLCUDA 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'));
}
}
+11 -2
View File
@@ -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") {
+7 -1
View File
@@ -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')
+10 -5
View File
@@ -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
@@ -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)