From 818531a26e50047f05ebaa846ef06ff038579555 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 15:35:37 +0200 Subject: [PATCH] feat(gamescope): a headless session now reports its own mode, and the perf overlay reaches the stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new patches on the pinned upstream, and the marker patch moves last so the banner is stamped after the capabilities it advertises. 0003 — headless: advertise the virtual display's mode and refresh rates. A headless gamescope is how we give a game a display: we pass the client's exact mode and the session runs at it. It never told anyone. CHeadlessConnector returned empty spans from GetModes() and GetValidDynamicRefreshRates() and reported GAMESCOPE_SCREEN_TYPE_INTERNAL, so update_mode_atoms DELETED the mode-list atom (no resolution list) and wlserver fell through to a one-entry refresh list built from g_nOutputRefresh (no refresh list). With --nested-refresh absent that entry is Init()'s 60 Hz default — which is why a field report on a 1920x1080@120 client saw "gamescope only shows 60hz, and there's no other option", and why Overwatch capped itself to 60 while the stream ran at 120. Populate both from the resolved mode, report EXTERNAL, and add --custom-refresh-rates so the offered set can be widened. gamescope-session-plus has probed for that flag for years; upstream never had it, so the CUSTOM_REFRESH_RATES env it plumbs was a no-op everywhere. 0004 — pipewire: optionally composite the external overlay into the capture stream. That layer is mangoapp: the fps/frametime readout the Deck UI turns on. paint_pipewire has never referenced it on any version, so a consumer whose only view of the session is the node sees the overlay it just enabled not appear, with nothing to configure. Behind --pipewire-composite-external-overlay, off by default, same argument as the cursor flag. Its commit id joins the repaint test — the numbers change while the picture behind them is static, exactly the case the existing test skips. Verified: the series git-am's cleanly onto the pinned 8c676c39, and both new functions were extracted verbatim and compiled with -Wall -Wextra under C++23 against stubs, with unit assertions for the parser and the mode/rate publication (sorting, dedup, the running rate always present, re-entrancy, zero rejected). A full gamescope build was not run — no box here has its dependency set; CI's per-Fedora-major leg is the first real compile. --- packaging/gamescope/PKGBUILD | 2 +- packaging/gamescope/README.md | 20 +- ...se-the-virtual-display-s-mode-and-re.patch | 247 ++++++++++++++++++ ...lly-composite-the-external-overlay-i.patch | 142 ++++++++++ ...tamp-the-version-banner-with-pfhdrN.patch} | 24 +- packaging/nix/gamescope.nix | 2 +- 6 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 packaging/gamescope/patches/0003-headless-advertise-the-virtual-display-s-mode-and-re.patch create mode 100644 packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch rename packaging/gamescope/patches/{0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch => 0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch} (65%) diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD index 17055a61..c4aadf0a 100644 --- a/packaging/gamescope/PKGBUILD +++ b/packaging/gamescope/PKGBUILD @@ -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=8c676c399c761e4540587f61004c957993d12fea -pkgver="${_gsver}.pfhdr2" +pkgver="${_gsver}.pfhdr4" pkgrel=1 pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming" arch=('x86_64' 'aarch64') diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index 6720d87e..d1b9bd84 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -13,7 +13,21 @@ The patches here add the missing half, and nothing else. See |---|---|---| | `0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch` | Offer SPA `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to `DRM_FORMAT_XRGB2101010`/`XBGR2101010`, and composite them with `g_ScreenshotColorMgmtLutsHDR` + `EOTF_PQ` | **Yes** — offered against [gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126) | | `0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch` | `--pipewire-composite-cursor` (off by default): paint the pointer into the capture stream, using the same `MouseCursor::paint` call the scanout composite uses | **Yes** — independently useful to any consumer with no cursor of its own | -| `0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr` to the `--version` banner | **No** — ours only, retired when the two above land upstream | +| `0003-headless-advertise-the-virtual-display-s-mode-and-re.patch` | Give `CHeadlessConnector` a real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `GAMESCOPE_SCREEN_TYPE_EXTERNAL` so `update_mode_atoms` publishes the list, and add `--custom-refresh-rates` | **Yes** — a headless session that cannot report its own mode is a plain bug | +| `0004-pipewire-optionally-composite-the-external-overlay-i.patch` | `--pipewire-composite-external-overlay` (off by default): paint the external overlay layer (mangoapp — the fps/stats readout) into the capture stream | **Yes** — same shape as the cursor patch, same argument | +| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream | + +### Why the headless patch matters + +A headless gamescope is how a streaming host gives a game a display: the caller passes the +client's exact mode and expects the session to run at it. It *does* — but it never told anyone. +`CHeadlessConnector` returned an empty span from both `GetModes()` and +`GetValidDynamicRefreshRates()` and reported `GAMESCOPE_SCREEN_TYPE_INTERNAL`, so +`update_mode_atoms()` **deleted** `GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL` (no resolution list) and +`wlserver_send_gamescope_control()` fell through to a **one-entry** refresh list built from +`g_nOutputRefresh` (no refresh list). With `-r` absent that entry is `Init()`'s 60 Hz default, so a +client on a 120 Hz panel was told its display was 60 Hz — and games capped themselves to it. Field +report 2026-08-08: "gamescope only shows 60hz and there's no other option". ### Why the cursor patch matters more than it looks @@ -40,6 +54,8 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap |---|---| | `+pfhdr1` | 10-bit BT.2020/PQ capture formats | | `+pfhdr2` | …and `--pipewire-composite-cursor` | +| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` | +| `+pfhdr4` | …and `--pipewire-composite-external-overlay` | Bump it whenever a patch adds or changes something the host must know about before it spawns. @@ -142,7 +158,7 @@ Note what is NOT in that table: the `.deb`. Debian/Ubuntu boxes build it by hand ## Verifying the patch on a box (P0 exit) ```sh -punktfunk-gamescope --version # must contain +pfhdr2 +punktfunk-gamescope --version # must contain +pfhdr4 punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \ --hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube & pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE diff --git a/packaging/gamescope/patches/0003-headless-advertise-the-virtual-display-s-mode-and-re.patch b/packaging/gamescope/patches/0003-headless-advertise-the-virtual-display-s-mode-and-re.patch new file mode 100644 index 00000000..693a5a88 --- /dev/null +++ b/packaging/gamescope/patches/0003-headless-advertise-the-virtual-display-s-mode-and-re.patch @@ -0,0 +1,247 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Enrico=20B=C3=BChler?= +Date: Sat, 8 Aug 2026 13:13:09 +0000 +Subject: [PATCH] headless: advertise the virtual display's mode and refresh + rates +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +A headless gamescope is how a remote-desktop/streaming host gives a game a +display: the caller passes the client's exact mode with -W/-H/-r and expects +the session to run at it. It does composite at that rate — but it never told +anyone. CHeadlessConnector returned an empty span from both GetModes() and +GetValidDynamicRefreshRates(), and reported GAMESCOPE_SCREEN_TYPE_INTERNAL. + +Both halves of that are visible to clients: + + * update_mode_atoms() takes the INTERNAL branch, which DELETES + GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL — so there is no resolution list. + * wlserver_send_gamescope_control() finds no valid dynamic refresh rates and + falls through to a one-entry list built from g_nOutputRefresh — so there is + no refresh list either, just whatever the session happens to run at. With + -r absent that is the 60 Hz default from Init(), and a client on a 120 Hz + panel is told its display is 60 Hz and caps itself accordingly. + +Populate both from the mode Init() has already resolved, and report EXTERNAL: +a virtual display is not a built-in panel, and INTERNAL is what suppressed the +mode list in the first place. GetConnector() follows so a lookup by type cannot +contradict the connector's own answer. + +--custom-refresh-rates lists the rates the display may switch between, for a +backend that has no EDID to derive them from (gamescope-session-plus already +passes this env through, gated on the flag existing). The running rate is always +included, so the advertised set can never exclude the mode in use. + +Only the headless backend changes; every other backend derives its modes from a +real connector and is untouched. +--- + src/Backends/HeadlessBackend.cpp | 66 ++++++++++++++++++++++++++++++-- + src/main.cpp | 41 ++++++++++++++++++++ + 2 files changed, 103 insertions(+), 4 deletions(-) + +diff --git a/src/Backends/HeadlessBackend.cpp b/src/Backends/HeadlessBackend.cpp +index 8e45400..3168c88 100644 +--- a/src/Backends/HeadlessBackend.cpp ++++ b/src/Backends/HeadlessBackend.cpp +@@ -3,8 +3,13 @@ + #include "wlserver.hpp" + #include "refresh_rate.h" + ++#include ++#include ++ + extern int g_nPreferredOutputWidth; + extern int g_nPreferredOutputHeight; ++// `--custom-refresh-rates` (main.cpp): the rates this virtual display may switch between. ++extern std::vector g_customRefreshRates; + + namespace gamescope + { +@@ -18,9 +23,16 @@ namespace gamescope + { + } + ++ // A headless gamescope IS a virtual display: its mode is whatever the caller asked ++ // for (`-W`/`-H`/`-r`), never a fixed built-in panel. Reporting INTERNAL made ++ // `update_mode_atoms` DELETE GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL and set the ++ // GAMESCOPE_CONTROL_DISPLAY_FLAG_INTERNAL_DISPLAY flag, so a client driving this ++ // session was offered no resolutions at all — and, with the empty rate list below, ++ // no refresh rates either. EXTERNAL is both the honest answer and the one that lets ++ // the mode list reach Steam. + virtual gamescope::GamescopeScreenType GetScreenType() const override + { +- return GAMESCOPE_SCREEN_TYPE_INTERNAL; ++ return GAMESCOPE_SCREEN_TYPE_EXTERNAL; + } + virtual GamescopePanelOrientation GetCurrentOrientation() const override + { +@@ -44,7 +56,7 @@ namespace gamescope + } + virtual std::span GetModes() const override + { +- return std::span{}; ++ return m_Modes; + } + + virtual bool SupportsVRR() const override +@@ -58,7 +70,7 @@ namespace gamescope + } + virtual std::span GetValidDynamicRefreshRates() const override + { +- return std::span{}; ++ return m_ValidDynamicRefreshRates; + } + + virtual void GetNativeColorimetry( +@@ -90,8 +102,42 @@ namespace gamescope + return 0; + } + ++ // Publish the mode this session was actually started with, plus every refresh rate it ++ // is allowed to switch between. Called once from CHeadlessBackend::Init(), after the ++ // -W/-H/-r defaults are resolved, because that is the first point at which the answer ++ // exists — and it has to exist before the first `gamescope_control` bind, which is ++ // what hands Steam the list. ++ void SetVirtualMode( uint32_t uWidth, uint32_t uHeight, uint32_t uRefreshHz, ++ std::span uOfferRatesHz ) ++ { ++ m_ValidDynamicRefreshRates.clear(); ++ m_Modes.clear(); ++ ++ auto AddRate = [ this ]( uint32_t uRate ) ++ { ++ if ( !uRate ) ++ return; ++ if ( std::find( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end(), uRate ) ++ == m_ValidDynamicRefreshRates.end() ) ++ m_ValidDynamicRefreshRates.push_back( uRate ); ++ }; ++ ++ for ( uint32_t uRate : uOfferRatesHz ) ++ AddRate( uRate ); ++ // The rate we are running at is always offerable, whatever the caller listed — ++ // otherwise Steam is handed a set that excludes the mode it is looking at. ++ AddRate( uRefreshHz ); ++ ++ std::sort( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end() ); ++ ++ for ( uint32_t uRate : m_ValidDynamicRefreshRates ) ++ m_Modes.push_back( BackendMode{ uWidth, uHeight, uRate } ); ++ } ++ + private: + BackendConnectorHDRInfo m_HDRInfo{}; ++ std::vector m_Modes; ++ std::vector m_ValidDynamicRefreshRates; + }; + + class CHeadlessBackend final : public CBaseBackend +@@ -125,6 +171,16 @@ namespace gamescope + if ( g_nOutputRefresh == 0 ) + g_nOutputRefresh = ConvertHztomHz( 60 ); + ++ // Hand the connector the resolved mode. Until this existed the headless connector ++ // advertised NOTHING — no modes, no dynamic refresh rates — so `wlserver`'s ++ // `active_display_info` fell through to a one-entry list built from g_nOutputRefresh ++ // and every client concluded the display was a 60 Hz panel it could not change. ++ m_Connector.SetVirtualMode( ++ uint32_t( g_nOutputWidth ), ++ uint32_t( g_nOutputHeight ), ++ ConvertmHzToHz( uint32_t( g_nOutputRefresh ) ), ++ g_customRefreshRates ); ++ + if ( !vulkan_init( vulkan_get_instance(), VK_NULL_HANDLE ) ) + { + return false; +@@ -200,7 +256,9 @@ namespace gamescope + } + virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override + { +- if ( eScreenType == GAMESCOPE_SCREEN_TYPE_INTERNAL ) ++ // Must agree with CHeadlessConnector::GetScreenType() — a lookup by type that ++ // contradicted the connector's own answer would hand callers the wrong screen. ++ if ( eScreenType == GAMESCOPE_SCREEN_TYPE_EXTERNAL ) + return &m_Connector; + + return nullptr; +diff --git a/src/main.cpp b/src/main.cpp +index 1eb35b3..2c4fb50 100644 +--- a/src/main.cpp ++++ b/src/main.cpp +@@ -92,6 +92,7 @@ const struct option *gamescope_options = (struct option[]){ + { "prefer-output", required_argument, nullptr, 'O' }, + { "default-touch-mode", required_argument, nullptr, 0 }, + { "generate-drm-mode", required_argument, nullptr, 0 }, ++ { "custom-refresh-rates", required_argument, nullptr, 0 }, + { "immediate-flips", no_argument, nullptr, 0 }, + { "framerate-limit", required_argument, nullptr, 0 }, + +@@ -232,6 +233,7 @@ const char usage[] = + " -O, --prefer-output list of connectors in order of preference (ex: DP-1,DP-2,DP-3,HDMI-A-1)\n" + " --default-touch-mode 0: hover, 1: left, 2: right, 3: middle, 4: passthrough\n" + " --generate-drm-mode DRM mode generation algorithm (cvt, fixed)\n" ++ " --custom-refresh-rates comma-separated refresh rates (Hz) this display may switch between, eg. 60,90,120 (headless only)\n" + " --immediate-flips Enable immediate flips, may result in tearing\n" + "\n" + #if HAVE_OPENVR +@@ -297,6 +299,10 @@ int g_nNestedHeight = 0; + int g_nNestedRefresh = 0; + int g_nNestedUnfocusedRefresh = 0; + int g_nNestedDisplayIndex = 0; ++// `--custom-refresh-rates`, in Hz. Consumed by the headless backend, which has no EDID to ++// derive a mode list from and so cannot answer "what else could this display run at" on its ++// own. Empty = offer only the rate the session was started at. ++std::vector g_customRefreshRates; + + uint32_t g_nOutputWidth = 0; + uint32_t g_nOutputHeight = 0; +@@ -447,6 +453,39 @@ static enum gamescope::GamescopeBackend parse_backend_name(const char *str) + } + } + ++// `--custom-refresh-rates 60,90,120` -> { 60, 90, 120 }. Anything unparseable is a hard error, ++// exactly like every other option value: silently dropping a rate would leave a display ++// advertising a list the caller never asked for, which is worse than not starting. ++static std::vector parse_refresh_rate_list(const char *str) ++{ ++ std::vector rates; ++ std::string_view svRest{ str }; ++ while ( !svRest.empty() ) ++ { ++ const size_t nComma = svRest.find( ',' ); ++ std::string_view svTok = svRest.substr( 0, nComma ); ++ svRest = nComma == std::string_view::npos ? std::string_view{} : svRest.substr( nComma + 1 ); ++ ++ // `Parse` is `std::from_chars`, which rejects leading blanks outright — trim so that a ++ // perfectly ordinary "60, 90, 120" is not an error. ++ while ( !svTok.empty() && svTok.front() == ' ' ) ++ svTok.remove_prefix( 1 ); ++ while ( !svTok.empty() && svTok.back() == ' ' ) ++ svTok.remove_suffix( 1 ); ++ ++ std::optional oRate = gamescope::Parse( svTok ); ++ // 1000 Hz is not a limit anyone will meet; it is there so a typo'd "1920" cannot become a ++ // refresh rate that every consumer then has to sanity-check for us. ++ if ( !oRate || *oRate == 0 || *oRate > 1000 ) ++ { ++ fprintf( stderr, "gamescope: invalid value for --custom-refresh-rates: %s\n", str ); ++ exit( 1 ); ++ } ++ rates.push_back( *oRate ); ++ } ++ return rates; ++} ++ + static int parse_integer(const char *str, const char *optionName) + { + auto result = gamescope::Parse(str); +@@ -800,6 +839,8 @@ int main(int argc, char **argv) + gamescope::cv_touch_click_mode = (gamescope::TouchClickMode) parse_integer( optarg, opt_name ); + } else if (strcmp(opt_name, "generate-drm-mode") == 0) { + g_eGamescopeModeGeneration = parse_gamescope_mode_generation( optarg ); ++ } else if (strcmp(opt_name, "custom-refresh-rates") == 0) { ++ g_customRefreshRates = parse_refresh_rate_list( optarg ); + } else if (strcmp(opt_name, "force-orientation") == 0) { + g_DesiredInternalOrientation = force_orientation( optarg ); + } else if (strcmp(opt_name, "sharpness") == 0 || diff --git a/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch b/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch new file mode 100644 index 00000000..924f5455 --- /dev/null +++ b/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch @@ -0,0 +1,142 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Enrico=20B=C3=BChler?= +Date: Sat, 8 Aug 2026 13:17:14 +0000 +Subject: [PATCH] pipewire: optionally composite the external overlay into the + capture stream +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +paint_pipewire() is a separate, reduced composite from the scanout one. It +paints the focus window, the override window and — since 3.16.23 — the Steam +overlay, but it has never referenced externalOverlayWindow. That layer is +mangoapp: the fps / frametime / stats readout the Deck UI turns on. + +On a real handheld the omission is invisible, because the person who enabled +the overlay is looking at the panel it is drawn on. For a consumer whose ONLY +view of the session is this node — a remote-desktop or streaming host — the +overlay simply does not exist: the user turns it on, sees nothing, and there is +nothing they can configure to change that. + +Add --pipewire-composite-external-overlay, off by default for the same reason +--pipewire-composite-cursor is: the node has never carried this layer, and a +consumer showing the stream to the same person already looking at the screen +would get two of them. + +Two details worth naming: + +- The overlay's commit id joins the repaint test. Its numbers change every + frame precisely while the picture behind them is static, which is exactly + the case the existing focus/override-only test skips — without this the + stream would show a frozen overlay. +- It is painted WITHOUT NoScale, unlike paint_all. There the overlay is + already sized to the output; here currentOutputWidth/Height are the capture + size, so a stream captured at another resolution needs it scaled to match. + +Notifications are deliberately left out: unlike a performance overlay, they are +not something the viewer asked to see on that screen. +--- + src/main.cpp | 3 +++ + src/steamcompmgr.cpp | 39 +++++++++++++++++++++++++++++++++++++++ + 2 files changed, 42 insertions(+) + +diff --git a/src/main.cpp b/src/main.cpp +index 2c4fb50..b406caf 100644 +--- a/src/main.cpp ++++ b/src/main.cpp +@@ -143,6 +143,7 @@ const struct option *gamescope_options = (struct option[]){ + { "disable-color-management", no_argument, nullptr, 0 }, + { "sdr-gamut-wideness", required_argument, nullptr, 0 }, + { "pipewire-composite-cursor", no_argument, nullptr, 0 }, ++ { "pipewire-composite-external-overlay", no_argument, nullptr, 0 }, + { "hdr-enabled", no_argument, nullptr, 0 }, + { "hdr-sdr-content-nits", required_argument, nullptr, 0 }, + { "hdr-itm-enabled", no_argument, nullptr, 0 }, +@@ -208,6 +209,8 @@ const char usage[] = + " --cursor-scale-height if specified, sets a base output height to linearly scale the cursor against.\n" + " --virtual-connector-strategy Specifies how we should make virtual connectors.\n" + " --pipewire-composite-cursor composite the cursor into the PipeWire capture stream (off by default: the node has never carried it, and a consumer that draws its own would get two)\n" ++ " --pipewire-composite-external-overlay\n" ++ " composite the external overlay layer (mangoapp) into the PipeWire capture stream (off by default, like the cursor)\n" + " --hdr-enabled enable HDR output (needs Gamescope WSI layer enabled for support from clients)\n" + " If this is not set, and there is a HDR client, it will be tonemapped SDR.\n" + " --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n" +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 5c65420..0d293c6 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -2323,6 +2323,12 @@ gamescope::ConVar cv_pipewire_composite_cursor{ "pipewire_composite_cursor + "default: the node has never carried the pointer, and a consumer that draws its own would get " + "two." }; + ++gamescope::ConVar cv_pipewire_composite_external_overlay{ "pipewire_composite_external_overlay", false, ++ "Composite the external overlay layer (mangoapp — the performance overlay) into the PipeWire " ++ "capture stream (--pipewire-composite-external-overlay). Off by default, like the cursor: the " ++ "node has never carried it, and a consumer showing the stream to the same person already " ++ "looking at the screen would get two." }; ++ + static void paint_pipewire() + { + static struct pipewire_buffer *s_pPipewireBuffer = nullptr; +@@ -2440,14 +2446,31 @@ static void paint_pipewire() + } + } + ++ // The external overlay — mangoapp, i.e. the fps/frametime/stats readout. `paint_all` draws ++ // it on the scanout composite; this reduced one never referenced it, so a consumer whose ++ // ONLY view of the session is this node could not see the overlay the user had turned on. ++ // Gated on the focus-appid for the same reason the Steam overlay above is: a consumer that ++ // asked for one specific app is asking for that app, not for the session's chrome. ++ static uint64_t s_ulLastExternalOverlayCommitId = 0; ++ steamcompmgr_win_t *pExternalOverlay = nullptr; ++ if ( cv_pipewire_composite_external_overlay && !ulFocusAppId && ++ pFocus->externalOverlayWindow && pFocus->externalOverlayWindow->opacity ) ++ pExternalOverlay = pFocus->externalOverlayWindow; ++ // Its commit id has to join the repaint test below, or the overlay would freeze at whatever ++ // it read when the game last presented — the numbers on it change every frame precisely ++ // WHILE the picture behind them is static, which is the case the test would otherwise skip. ++ const uint64_t ulExternalOverlayCommitId = window_last_done_commit_id( pExternalOverlay ); ++ + if ( ulFocusCommitId == s_ulLastFocusCommitId && + ulOverrideCommitId == s_ulLastOverrideCommitId && ++ ulExternalOverlayCommitId == s_ulLastExternalOverlayCommitId && + bDrawCursor == s_bLastCursorDrawn && + nCursorX == s_nLastCursorX && nCursorY == s_nLastCursorY ) + return; + + s_ulLastFocusCommitId = ulFocusCommitId; + s_ulLastOverrideCommitId = ulOverrideCommitId; ++ s_ulLastExternalOverlayCommitId = ulExternalOverlayCommitId; + s_bLastCursorDrawn = bDrawCursor; + s_nLastCursorX = nCursorX; + s_nLastCursorY = nCursorY; +@@ -2475,6 +2498,16 @@ static void paint_pipewire() + ( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) ); + } + ++ // Deliberately WITHOUT `NoScale`, which is what the scanout composite uses: there the ++ // overlay is already sized to the output, while here `currentOutputWidth/Height` are the ++ // capture size, and a stream captured at anything other than the session's own resolution ++ // would otherwise get the overlay at the wrong size in the corner. ++ if ( pExternalOverlay ) ++ { ++ paint_window( pExternalOverlay, pExternalOverlay, &frameInfo, nullptr, PaintWindowFlag::NoFilter | ++ ( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) ); ++ } ++ + // The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the + // PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play + // consumer that draws its own would end up with two — so a consumer that has no cursor of +@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv) + cv_pipewire_composite_cursor = true; + #else + fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" ); ++#endif ++ } else if (strcmp(opt_name, "pipewire-composite-external-overlay") == 0) { ++#if HAVE_PIPEWIRE ++ cv_pipewire_composite_external_overlay = true; ++#else ++ fprintf( stderr, "gamescope: --pipewire-composite-external-overlay ignored (built without PipeWire)\n" ); + #endif + } else if (strcmp(opt_name, "hdr-enabled") == 0 || strcmp(opt_name, "hdr-enable") == 0) { + cv_hdr_enabled = true; diff --git a/packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch b/packaging/gamescope/patches/0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch similarity index 65% rename from packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch rename to packaging/gamescope/patches/0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch index d2c9d9ce..2f4b7724 100644 --- a/packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch +++ b/packaging/gamescope/patches/0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch @@ -1,6 +1,6 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrico=20B=C3=BChler?= -Date: Tue, 28 Jul 2026 15:42:01 +0200 +Date: Sat, 8 Aug 2026 13:17:37 +0000 Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 @@ -8,25 +8,27 @@ Content-Transfer-Encoding: 8bit punktfunk decides a session's shape before the virtual display exists — the bit depth in the Welcome (irrevocable; a PQ stream on an 8-bit encoder is a -hard error), and whether it must composite the cursor host-side before the -encoder is even opened. Both answers therefore have to be static properties of -the resolved binary rather than something negotiated later. +hard error), whether it must composite the cursor host-side before the encoder +is even opened, and which flags the spawn has to carry. All of those have to be +static properties of the resolved binary rather than something negotiated later. -The number is a monotonic patch-set revision, so one probe answers both: +The number is a monotonic patch-set revision, so one probe answers all of them: +pfhdr1 10-bit BT.2020/PQ capture formats +pfhdr2 …and --pipewire-composite-cursor + +pfhdr3 …and the headless connector advertises its mode + --custom-refresh-rates + +pfhdr4 …and --pipewire-composite-external-overlay NOT for upstream: drop this once the functional patches land there and plain version floors answer the same questions. --- - src/meson.build | 7 ++++++- - 1 file changed, 6 insertions(+), 1 deletion(-) + src/meson.build | 9 ++++++++- + 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/meson.build b/src/meson.build -index 662f752..af48d01 100644 +index 662f752..12fd38a 100644 --- a/src/meson.build +++ b/src/meson.build -@@ -177,7 +177,12 @@ compiler_version = cc.version() +@@ -177,7 +177,14 @@ compiler_version = cc.version() vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+'] vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip() @@ -36,7 +38,9 @@ index 662f752..af48d01 100644 +# punktfunk/1 Welcome is irrevocable). The number is a monotonic PATCH-SET revision: +# +pfhdr1 — 10-bit BT.2020/PQ capture formats on the PipeWire node +# +pfhdr2 — …and `--pipewire-composite-cursor` -+version_tag = vcs_tag + '+pfhdr2' + ' (' + compiler_name + ' ' + compiler_version + ')' ++# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates` ++# +pfhdr4 — …and `--pipewire-composite-external-overlay` ++version_tag = vcs_tag + '+pfhdr4' + ' (' + compiler_name + ' ' + compiler_version + ')' gamescope_version_conf = configuration_data() gamescope_version_conf.set('VCS_TAG', version_tag) diff --git a/packaging/nix/gamescope.nix b/packaging/nix/gamescope.nix index 531c617b..55078b5f 100644 --- a/packaging/nix/gamescope.nix +++ b/packaging/nix/gamescope.nix @@ -63,7 +63,7 @@ unwrapped.overrideAttrs (old: { ); # nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the - # banner would read `+pfhdr2 (gcc …)` with no version at all — which the host's diagnostic + # banner would read `+pfhdrN (gcc …)` with no version at all — which the host's diagnostic # version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's). # Substituting the real version in keeps `--version` honest AND keeps our marker. postPatch = (old.postPatch or "") + ''