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 "") + ''