From 818531a26e50047f05ebaa846ef06ff038579555 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 15:35:37 +0200 Subject: [PATCH 1/3] 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 "") + '' -- 2.54.0 From 102f550bba27197fe28bff932484e05381bb427c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 15:35:58 +0200 Subject: [PATCH 2/3] feat(host): use the new gamescope capabilities, and say so when the mode is lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass --custom-refresh-rates (patch level 3+) and --pipewire-composite-external-overlay (level 4+) on both spawn paths, with the same probe-then-pass shape the HDR and cursor flags already use. A stock gamescope has neither flag and gets neither, which is exactly today's behaviour. New knob PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 widens the set a session offers in Steam's in-session display settings. The rate the session actually runs at is always included, so it can only add options; junk entries are skipped rather than failing the host, because the worst a typo can cost is the extra option the operator wanted. And the part that would have turned a week of field triage into one log line: warn_if_mode_lost(). --nested-refresh is the ONLY refresh a headless gamescope has, and it reaches a gamescope-session-plus solely through the GAMESCOPE_BIN wrapper, which the session script is free to lose — a sessions.d file sourced with `set -a` can reassign GAMESCOPE_BIN, and one that sets GAMESCOPECMD outright skips the whole builder. When that happens the stream still runs, still looks right, and the client's own fps counter still reads the negotiated rate (the encode loop repeats the held frame), while the game underneath is capped to 60. Nothing anywhere said so. It warns rather than refusing, deliberately: verify_managed_spawn_flags refuses because its retry resolves a different plan, but a relaunch here would hand the session the same environment and lose the mode the same way, so refusing would only loop. Fails open on the same rule as the flag check — nothing to compare against says nothing. Also corrects the comment above the launch env, which claimed CUSTOM_REFRESH_RATES "generates the mode the session ADVERTISES … what makes games see the real refresh". It never did: no upstream gamescope has --custom-refresh-rates, so gamescope_has_option gated it off and the variable was inert. That belief is why the real lever went unexamined. configuration.md gains the new knob and a warning on PUNKTFUNK_MAX_FPS, which also lowers the refresh the session REPORTS on gamescope — the docs said it does not cap the stream, which is true of the wire and not of what games are told. Linux-verified on Ubuntu: cargo check --all-targets, clippy -D warnings, 133 tests (2 new), cargo fmt --check. --- crates/pf-host-config/src/lib.rs | 50 ++++ .../src/vdisplay/linux/gamescope.rs | 255 ++++++++++++++++-- .../src/vdisplay/linux/gamescope/discovery.rs | 28 ++ docs-site/content/docs/configuration.md | 3 +- 4 files changed, 314 insertions(+), 22 deletions(-) diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index a74ff9e3..64a24e71 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -260,6 +260,18 @@ pub struct HostConfig { /// encode, so this is the knob that decides how bright "white" looks on the client's panel. /// `None` = leave gamescope's own default. pub gamescope_sdr_nits: Option, + /// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope + /// session offers its clients on top of the one it runs at, e.g. `60,90,120`. + /// + /// A headless gamescope has no EDID, so it cannot work out what else its display could run at: + /// on a stock build it advertises exactly ONE rate and Steam's in-session display settings show + /// a single entry. Our `+pfhdr3` build takes this list (`--custom-refresh-rates`) and publishes + /// it, which is what puts real choices in that menu. The session's own rate is always included + /// whatever is set here, so this can only ever ADD options. + /// + /// Empty (the default) = advertise only the negotiated rate. Ignored on a stock gamescope, + /// which has no flag to take it. + pub gamescope_refresh_rates: Vec, /// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO /// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell /// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up @@ -379,6 +391,12 @@ impl HostConfig { gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS") .and_then(|s| s.trim().parse::().ok()) .filter(|n| (1..=10_000).contains(n)), + // Unparseable entries are DROPPED rather than failing the host: this only ever widens a + // menu, and the session's own rate is added back unconditionally, so the worst a typo + // can cost is the extra option the operator wanted — never the session. + gamescope_refresh_rates: parse_refresh_rates( + val("PUNKTFUNK_GAMESCOPE_REFRESH_RATES").as_deref(), + ), recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") .filter(|s| !s.trim().is_empty()), on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()), @@ -397,6 +415,20 @@ impl HostConfig { } } +/// `"60, 90,120"` → `[60, 90, 120]`, sorted and deduped. Junk entries and out-of-range rates are +/// skipped rather than rejected wholesale — see the call site for why. Pure + unit-tested. +fn parse_refresh_rates(raw: Option<&str>) -> Vec { + let mut out: Vec = raw + .unwrap_or_default() + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|&hz| (1..=1000).contains(&hz)) + .collect(); + out.sort_unstable(); + out.dedup(); + out +} + impl HostConfig { /// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by /// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's @@ -446,6 +478,24 @@ mod tests { assert_eq!(c.game_fps(0), 0); } + #[test] + fn refresh_rate_list_parses_and_tolerates_junk() { + assert_eq!(parse_refresh_rates(Some("60,90,120")), vec![60, 90, 120]); + // Spaces, unsorted input and duplicates all normalise. + assert_eq!( + parse_refresh_rates(Some(" 120, 60 ,90, 60")), + vec![60, 90, 120] + ); + // Unset and empty are the default: advertise only the session's own rate. + assert!(parse_refresh_rates(None).is_empty()); + assert!(parse_refresh_rates(Some("")).is_empty()); + assert!(parse_refresh_rates(Some(" ")).is_empty()); + // A typo costs its own entry, never the whole list — the knob only widens a menu. + assert_eq!(parse_refresh_rates(Some("60,abc,120")), vec![60, 120]); + // Out of range in both directions (0 is not a refresh rate; 1920 is a width). + assert_eq!(parse_refresh_rates(Some("0,60,1920")), vec![60]); + } + #[test] fn audio_output_mode_parses_its_spellings() { for (s, want) in [ diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index d7cf75b5..53538899 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -27,6 +27,7 @@ mod heads; mod splash; use discovery::{ check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin, + gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates, gamescope_node_present, poll_managed_node, wait_for_node, }; pub(crate) use discovery::{ @@ -1153,17 +1154,9 @@ fn gamescope_argvs() -> Vec> { /// also the final filter that separates a compositor from anything else [`gamescope_argvs`] let by. fn current_gamescope_output_size() -> Option<(u32, u32)> { gamescope_argvs().into_iter().find_map(|args| { - let flag = |names: &[&str]| -> Option { - args.iter().enumerate().find_map(|(i, a)| { - names - .contains(&a.as_str()) - .then(|| args.get(i + 1).and_then(|v| v.parse().ok())) - .flatten() - }) - }; match ( - flag(&["-W", "--output-width"]), - flag(&["-H", "--output-height"]), + argv_u32(&args, &["-W", "--output-width"]), + argv_u32(&args, &["-H", "--output-height"]), ) { (Some(w), Some(h)) => Some((w, h)), _ => None, @@ -1171,6 +1164,104 @@ fn current_gamescope_output_size() -> Option<(u32, u32)> { }) } +/// The numeric value following the first of `names` present in `argv`. Pure + unit-tested — it is +/// the shared reader behind both the output-size probe above and the mode verification below. +fn argv_u32(argv: &[String], names: &[&str]) -> Option { + argv.iter().enumerate().find_map(|(i, a)| { + names + .contains(&a.as_str()) + .then(|| argv.get(i + 1).and_then(|v| v.parse().ok())) + .flatten() + }) +} + +/// Did the MODE we asked an indirectly-spawned session for actually reach its gamescope? +/// +/// [`verify_managed_spawn_flags`] answers the same question for the capability flags and REFUSES +/// the session when they are missing, because the retry then resolves a different (correct) plan. +/// The mode has no such recovery: relaunching would hand the session the exact same environment and +/// lose it the same way, so refusing would only loop. It is not silent either, though — and it used +/// to be, in the way that costs the most: +/// +/// `--nested-refresh` is the ONLY refresh a headless gamescope has. `CHeadlessBackend::Init` +/// assigns `g_nOutputRefresh = g_nNestedRefresh`, defaulting to **60 Hz** when the flag is absent, +/// and that one number is what the session composites at, what `vblankmanager` paces to, and what +/// Steam and every game are told the display runs at. It reaches a `gamescope-session-plus` only +/// through the `GAMESCOPE_BIN` wrapper — which the session script is free to lose (a `sessions.d` +/// file sourced with `set -a` can reassign `GAMESCOPE_BIN`; one that sets `GAMESCOPECMD` outright +/// skips the whole builder). When that happened the stream still ran, still looked right, and still +/// showed the client's own fps counter at the negotiated rate — because the encode loop repeats the +/// held frame — while the game underneath was capped to 60. Field report 2026-08-08. +/// +/// So: warn, name the numbers, and carry on. Same "any running gamescope carrying it" rule as the +/// flag check, and the same silence when `/proc` cannot be read. +fn warn_if_mode_lost(mode: Mode, want_hz: u32) { + let argvs = gamescope_argvs(); + let lost = mode_mismatch(mode.width, mode.height, want_hz, &argvs); + if lost.is_empty() { + return; + } + tracing::warn!( + lost = %lost.join(", "), + "gamescope: the session did not start at the mode we asked for — the session script \ + dropped GAMESCOPE_BIN / SCREEN_WIDTH / SCREEN_HEIGHT. A headless gamescope reports \ + `--nested-refresh` as its ONE refresh rate (60 Hz when the flag never arrives), so games \ + and Steam will believe the display runs at that rate however fast the stream is. Install \ + punktfunk-gamescope, or check /etc/gamescope-session-plus/sessions.d/ for a file that \ + overrides GAMESCOPE_BIN or sets GAMESCOPECMD" + ); +} + +/// Which parts of the requested mode no running gamescope was started with, as human-readable +/// `asked=…, got=…` fragments. Empty when it matches — or when there is nothing to compare against, +/// which is the same fail-open rule [`missing_flags`] has and for the same reason. Pure + +/// unit-tested. +fn mode_mismatch(want_w: u32, want_h: u32, want_hz: u32, argvs: &[Vec]) -> Vec { + if argvs.is_empty() { + return Vec::new(); + } + let mut lost = Vec::new(); + let sizes: Vec<(u32, u32)> = argvs + .iter() + .filter_map(|a| { + Some(( + argv_u32(a, &["-W", "--output-width"])?, + argv_u32(a, &["-H", "--output-height"])?, + )) + }) + .collect(); + // No gamescope carries an output size at all → we cannot tell ours apart from a nested one; + // stay quiet rather than warn on every box that runs a second gamescope. + if !sizes.is_empty() && !sizes.contains(&(want_w, want_h)) { + lost.push(format!( + "resolution asked={want_w}x{want_h}, got={}", + sizes + .iter() + .map(|(w, h)| format!("{w}x{h}")) + .collect::>() + .join("/") + )); + } + let rates: Vec = argvs + .iter() + .filter_map(|a| argv_u32(a, &["-r", "--nested-refresh"])) + .collect(); + if !rates.contains(&want_hz) { + lost.push(match rates.as_slice() { + // The flag is absent everywhere — the exact shape that silently yields 60 Hz. + [] => format!( + "refresh asked={want_hz}Hz, got=no --nested-refresh at all (gamescope defaults to \ + 60Hz headless)" + ), + got => format!( + "refresh asked={want_hz}Hz, got={}Hz", + got.iter().map(u32::to_string).collect::>().join("/") + ), + }); + } + lost +} + /// Did the flags we passed an INDIRECTLY-spawned session actually reach its gamescope? /// /// The bare spawn builds argv itself and cannot lose them. The two managed modes can: a @@ -2337,12 +2428,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul let wrapper = write_gamescope_bin_wrapper()?; stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean let hz = mode.refresh_hz.max(1); - // The two rates are deliberately different when the frame limiter is set. CUSTOM_REFRESH_RATES - // generates the mode the session ADVERTISES, which must stay the client's — that is what makes - // games see the real refresh instead of the box's EDID. PF_HZ becomes `--nested-refresh`, the - // rate the game is clamped to, and is the only one the limiter touches. Identical when it's - // unset, which is the default. + // ONE rate reaches gamescope, and it is `--nested-refresh` (via the wrapper's `PF_HZ`). On the + // headless backend that flag IS the output refresh — `CHeadlessBackend::Init` assigns + // `g_nOutputRefresh = g_nNestedRefresh` — so it is simultaneously the rate the session + // composites at, the rate `vblankmanager` paces to, and the rate Steam and every game are told + // the display runs at. When the frame limiter (`PUNKTFUNK_MAX_FPS`) is set they all drop + // together; that is the trade the knob is, and it is off by default. + // + // `CUSTOM_REFRESH_RATES` below does NOT do this, whatever its name suggests: it is the *set* of + // rates the session may offer, and `gamescope-session-plus` gates it on the binary having + // `--custom-refresh-rates`, which no upstream gamescope has ever had. On a stock gamescope it + // is inert (it was a silent no-op for years); on our `+pfhdr3` build it is what puts more than + // one entry in Steam's refresh menu. Either way it cannot fix a wrong `--nested-refresh`. let game = game_hz(mode.refresh_hz); + // The advertised SET, which always contains the rate we actually run at. + let offered = { + let mut r = pf_host_config::config().gamescope_refresh_rates.clone(); + if !r.contains(&hz) { + r.push(hz); + } + r.sort_unstable(); + r.dedup(); + r.iter().map(u32::to_string).collect::>().join(",") + }; let start_unit = || -> Result<()> { let status = Command::new("systemd-run") .args(["--user", "--collect", &format!("--unit={unit_name}")]) @@ -2366,7 +2474,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul )) .arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display())) .arg("--setenv=DRM_MODE=cvt") - .arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}")) + .arg(format!("--setenv=CUSTOM_REFRESH_RATES={offered}")) .arg("--") .arg(SESSION_PLUS_BIN) .arg(client) @@ -2394,6 +2502,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul stop_session(unit_name); return Err(e); } + // Loud, but not fatal — see [`warn_if_mode_lost`] for why this one warns where the + // capability flags above refuse. + warn_if_mode_lost(mode, game); return Ok(id); } if Instant::now() >= deadline { @@ -2526,7 +2637,15 @@ fn add_bare_gamescope_args( if grab_cursor { command.arg("--force-grab-cursor"); } - for arg in hdr_args(hdr).into_iter().chain(cursor_args()) { + // `-r` above is what this headless session will REPORT as its refresh (the headless backend + // assigns `g_nOutputRefresh = g_nNestedRefresh`), so it is already correct here. This adds the + // rest of the SET the in-session UI may offer — the bare spawn passes it directly, with none of + // the session-script indirection the managed path has to route it through. + for arg in hdr_args(hdr) + .into_iter() + .chain(cursor_args()) + .chain(refresh_rate_args(hz)) + { command.arg(arg); } command.args(["--xwayland-count", "1", "--"]); @@ -2571,11 +2690,48 @@ fn hdr_args(hdr: bool) -> Vec { /// host-side (it costs the host a full-frame pass, and on the zero-CSC encode source it cannot be /// done at all). Empty on a stock gamescope, which is exactly the old behaviour. fn cursor_args() -> Vec { + let mut args = Vec::new(); if gamescope_can_composite_cursor() { - vec!["--pipewire-composite-cursor".to_string()] - } else { - Vec::new() + args.push("--pipewire-composite-cursor".to_string()); } + // The external overlay (mangoapp — the Deck UI's fps/frametime readout, patch level 4+). Unlike + // the cursor there is no host-side fallback: the host cannot reconstruct another process's + // overlay window, so without this the layer is simply absent from every gamescope stream. + if gamescope_can_composite_external_overlay() { + args.push("--pipewire-composite-external-overlay".to_string()); + } + args +} + +/// `--custom-refresh-rates ` when the resolved gamescope has it (patch level 3+): the rates a +/// HEADLESS session may offer its clients. +/// +/// Without it a headless connector advertises exactly one rate, so Steam's in-session display +/// settings show a single entry and a game reads the display as that one number. `session_hz` is +/// always in the list — it is the mode the session actually runs at, and an advertised set that +/// excluded it would be a lie in the other direction. +/// +/// The operator can widen the set (`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`) so the in-session +/// UI offers real choices; unset, we advertise the one rate we run at, which is what the client +/// asked for. +fn refresh_rate_args(session_hz: u32) -> Vec { + if !gamescope_can_offer_refresh_rates() { + return Vec::new(); + } + let mut rates = pf_host_config::config().gamescope_refresh_rates.clone(); + if !rates.contains(&session_hz) { + rates.push(session_hz); + } + rates.sort_unstable(); + rates.dedup(); + vec![ + "--custom-refresh-rates".to_string(), + rates + .iter() + .map(u32::to_string) + .collect::>() + .join(","), + ] } /// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from @@ -2717,7 +2873,7 @@ mod tests { use super::{ cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under, display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args, - is_steam_launch, missing_flags, nested_wrapper_script, sentinel_advanced, + is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced, shape_dedicated_command, }; @@ -2949,6 +3105,63 @@ mod tests { assert!(!cgroup_is_punktfunk_owned("")); } + /// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh + /// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the + /// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 — + /// the exact shape of the 2026-08-08 field report, and invisible without this. + #[test] + fn mode_mismatch_names_what_the_session_actually_got() { + let argv = |s: &str| -> Vec { s.split(' ').map(str::to_string).collect() }; + + // The good case: our own managed spawn, carrying everything we asked for. + let ok = vec![argv( + "/usr/bin/gamescope --backend headless -W 1920 -H 1080 --nested-refresh 120 --steam", + )]; + assert!(mode_mismatch(1920, 1080, 120, &ok).is_empty()); + + // THE field case: the wrapper was dropped, so there is no `--nested-refresh` anywhere and + // gamescope silently ran its 60 Hz default. Size still landed (SCREEN_WIDTH survived). + let lost = vec![argv( + "/usr/bin/gamescope --backend headless -W 1920 -H 1080 --steam", + )]; + let got = mode_mismatch(1920, 1080, 120, &lost); + assert_eq!(got.len(), 1, "only the refresh is wrong: {got:?}"); + assert!(got[0].contains("asked=120Hz"), "{got:?}"); + assert!(got[0].contains("no --nested-refresh at all"), "{got:?}"); + + // A wrong rate is reported with the number it actually got, not just "missing". + let wrong = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh 60")]; + let got = mode_mismatch(1920, 1080, 120, &wrong); + assert_eq!(got.len(), 1); + assert!(got[0].contains("got=60Hz"), "{got:?}"); + + // Resolution lost too (SCREEN_WIDTH/HEIGHT dropped as well) — both are named. + let both = vec![argv("gamescope -W 1280 -H 720")]; + assert_eq!(mode_mismatch(1920, 1080, 120, &both).len(), 2); + + // Fail OPEN, exactly like `missing_flags`: nothing to compare against says nothing. A box + // with a second gamescope that carries no output size must not produce a false alarm. + assert!(mode_mismatch(1920, 1080, 120, &[]).is_empty()); + + // ANY running gamescope carrying the mode satisfies it — a Deck commonly runs a nested one + // beside the session, and demanding that every gamescope match would reject a good session. + let two = vec![ + argv("gamescope -W 1280 -H 800 --nested-refresh 60"), + argv("gamescope -W 1920 -H 1080 --nested-refresh 120"), + ]; + assert!(mode_mismatch(1920, 1080, 120, &two).is_empty()); + + // The long spellings are read too. + let long = vec![argv( + "gamescope --output-width 1920 --output-height 1080 --nested-refresh 120", + )]; + assert!(mode_mismatch(1920, 1080, 120, &long).is_empty()); + + // A flag with no value after it must not panic or read past the end. + let truncated = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh")]; + assert_eq!(mode_mismatch(1920, 1080, 120, &truncated).len(), 1); + } + /// The silent-cursor guard: a managed session that ignored `GAMESCOPE_BIN` / the PATH shim runs /// a stock gamescope, and the host — already told the compositor would paint the pointer — /// paints none either. Only a compositor we can SEE, missing a flag we can NAME, may fail. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 4b90cabb..82e7a7df 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -449,6 +449,34 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool { gamescope_patch_level() >= 2 && !flags_lost() } +/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may +/// offer (`--custom-refresh-rates`)? +/// +/// Below this level a headless gamescope advertises **one** rate — whatever `--nested-refresh` +/// resolved to, or its own 60 Hz default — and no resolution list at all, because its connector +/// returns empty spans from `GetModes()`/`GetValidDynamicRefreshRates()` and reports an INTERNAL +/// screen (which makes `update_mode_atoms` delete the mode-list atom outright). So on a stock +/// gamescope, Steam's in-session display settings show exactly one refresh rate and no +/// resolutions, and games read the display as 60 Hz whatever the client negotiated. +/// +/// `gamescope-session-plus` has probed for this flag for years (`CUSTOM_REFRESH_RATES` is gated on +/// `gamescope --help` mentioning it) — upstream simply never had it, so the env var it plumbs was +/// a no-op everywhere. +pub(crate) fn gamescope_can_offer_refresh_rates() -> bool { + gamescope_patch_level() >= 3 && !flags_lost() +} + +/// Can the resolved gamescope paint the EXTERNAL OVERLAY — mangoapp, the Deck-UI fps/frametime +/// readout — into its PipeWire node (`--pipewire-composite-external-overlay`)? +/// +/// `paint_pipewire` has never referenced that layer on any upstream version, so a client whose +/// only view of the session is the node sees the overlay it just enabled simply not appear. +/// Unlike the cursor there is no host-side substitute: the host cannot reconstruct someone else's +/// overlay window. +pub(crate) fn gamescope_can_composite_external_overlay() -> bool { + gamescope_patch_level() >= 4 && !flags_lost() +} + /// Has a spawn been observed where our flags did NOT reach the gamescope process? /// /// The binary probe above answers "can it", which is all the bare spawn needs — there we build diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 192a7ed9..7b305475 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -134,7 +134,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | -| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. | +| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. | +| `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` | e.g. `60,90,120` *(default: just the session's own rate)* | Extra refresh rates a gamescope session **offers** in its in-session display settings. A headless gamescope has no EDID, so it cannot work out what else the display could run at — without this it advertises exactly one rate and Steam's refresh menu has a single entry. The rate the session actually runs at is always included, so this can only add options. Needs the `punktfunk-gamescope` build (`+pfhdr3`); ignored on a stock gamescope, which has no flag to take it. | | `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1`–`4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. | ## Gamepads -- 2.54.0 From eb8c9435728bf85b9091614765252fdec75e9398 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 15:36:19 +0200 Subject: [PATCH 3/3] feat(packaging): ship punktfunk-gamescope on RPM and apt too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the patched gamescope reached exactly four kinds of box: the Bazzite/Fedora-Atomic sysext, the Arch package, the SteamOS installer and a NixOS option. Everyone else was told to build gamescope from source. A traditional Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from — therefore ran stock gamescope by default, which streams SDR, cursorless, and tells every game its display is 60 Hz. That is not a user error; there was no package to install. Both new packages REPACK the binary CI already builds rather than building gamescope again: it is a ~10-minute meson compile of an unrelated tree, cached per distro base because the binary is soname-coupled to it. The Arch PKGBUILD stays the one recipe that builds from source, because that is what makepkg is for. - packaging/gamescope/punktfunk-gamescope.spec + build-gamescope-rpm.sh. Version is derived from the binary's own banner (3.16.25.pfhdr4) — the only source that cannot drift from what is in the package. rpmbuild's automatic ELF Requires are what stop an f43 build installing on f44. - packaging/debian/build-gamescope-deb.sh, same shape, with dpkg-shlibdeps for Depends. - rpm.yml packages and publishes it beside the host RPMs; deb.yml gains a cached gamescope build (keyed on packaging/gamescope/** alone) and packages it into the existing publish loop. Both legs are best-effort, matching the sysext's existing rule: no binary, no package, and the host stays on its current SDR path. Neither package Provides or Conflicts with gamescope — it installs as /usr/bin/punktfunk-gamescope and only the sessions the host starts itself resolve it, so a box's own Game Mode keeps using the distro binary. Both refuse to package a binary without the +pfhdr marker. That marker is the host's entire capability probe, so a build that lost the patches would install fine and then silently stream SDR with no cursor. Verified: build-gamescope-deb.sh produces an installable .deb from a stand-in binary (correct version derived from the banner, 0755 tree, control fields) and exits 1 on an unmarked one. The .spec is not yet exercised — no rpm tooling on the box I had; CI's Fedora leg is its first run. --- .gitea/workflows/deb.yml | 40 +++++++ .gitea/workflows/rpm.yml | 34 ++++++ docs-site/content/docs/gamescope.md | 19 ++++ packaging/debian/build-gamescope-deb.sh | 113 +++++++++++++++++++ packaging/gamescope/build-gamescope-rpm.sh | 78 +++++++++++++ packaging/gamescope/punktfunk-gamescope.spec | 78 +++++++++++++ 6 files changed, 362 insertions(+) create mode 100755 packaging/debian/build-gamescope-deb.sh create mode 100755 packaging/gamescope/build-gamescope-rpm.sh create mode 100644 packaging/gamescope/punktfunk-gamescope.spec diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index bcf7c1b8..2e95f73c 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -320,6 +320,46 @@ jobs: run: | VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh + # punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged + # build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a + # stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz. + # + # CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a + # normal push restores a binary instead of spending ~10 minutes on someone else's tree. + - uses: actions/cache@v4 + id: gamescope + with: + path: gs-cache + key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }} + + - name: Build the patched gamescope + if: steps.gamescope.outputs.cache-hit != 'true' + # Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and + # work without this binary, so a hiccup building an unrelated tree must not fail the job. + # `build-dep gamescope` resolves the distro's much older packaged version, so it can come up + # short — that is what the `|| true`s absorb, and the marker check downstream is what makes + # a half-built result impossible to ship. + run: | + set -x + apt-get update + apt-get install -y --no-install-recommends meson ninja-build glslc git || true + apt-get build-dep -y gamescope || true + if bash packaging/gamescope/build-punktfunk-gamescope.sh \ + --destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then + install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope + else + echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)" + fi + + - name: Build punktfunk-gamescope .deb + # Picked up by the publish loop below, which globs dist/*.deb. + run: | + if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then + bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope + else + echo "::warning::no usable punktfunk-gamescope — skipping its .deb" + fi + - name: Publish to the Gitea apt registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 1769e661..675395c6 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -213,6 +213,40 @@ jobs: echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)" fi + # The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this + # is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install — + # until it existed those users had no packaged route to the patched build at all, and a stock + # gamescope tells every game its display is 60 Hz whatever the client negotiated. + # + # Same best-effort rule as the build above: no binary, no package, and the host stays on its + # existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself. + - name: Package punktfunk-gamescope as an RPM + run: | + if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then + bash packaging/gamescope/build-gamescope-rpm.sh \ + --binary gs-cache/punktfunk-gamescope \ + --release "$PF_RELEASE" + else + echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM" + fi + + - name: Publish punktfunk-gamescope to the Gitea RPM registry + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + shopt -s nullglob + for rpm in dist/punktfunk-gamescope-*.rpm; do + case "$rpm" in *debuginfo*|*debugsource*) continue;; esac + NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null) + VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null) + ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null) + echo "uploading $rpm" + curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ + "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true + curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ + "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload" + done + # The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext # image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that # `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree. diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index d23ba2d9..2bcd7c9e 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -161,6 +161,9 @@ To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one. - **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it. +- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same + Punktfunk repo the host comes from. +- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo. - **Arch** — the `punktfunk-gamescope` package. - **SteamOS (Steam Deck installer)** — built and wired automatically by `scripts/steamdeck/install.sh` / `update.sh`. @@ -200,6 +203,22 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this only bites if you've pinned an old one. +- **On a stock gamescope, Gaming Mode reports the wrong refresh rate — and offers no resolutions.** + A headless gamescope has no EDID, and upstream's headless connector advertises no display modes + and no refresh rates at all. Steam's in-session display settings then show a single refresh entry + and an empty resolution list, and that one entry is whatever the session was launched with — or + **60 Hz** if the launch flag went missing. Games that pace themselves to the display will hold + themselves there, even though the stream is running at your client's full rate (the client's own + fps counter keeps reading correctly, because the encoder repeats held frames — so the counter is + not the thing to trust here; an in-game fps readout is). `punktfunk-gamescope` publishes the real + mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that + menu. If the host log says *"the session did not start at the mode we asked for"*, a file in + `/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`. +- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp, + which gamescope draws as an *external overlay* — a layer upstream's capture composite has never + included on any version, so on a stock gamescope you can turn the overlay on and it simply will + not appear in the stream. There is no host-side substitute: the host cannot reconstruct another + process's overlay window. `punktfunk-gamescope` paints it into the capture stream. - **The cursor comes from the compositor when it can, and from the host otherwise.** A stock gamescope leaves the pointer out of its captured image, so the host reads it separately and draws it into every frame — a full pass over the picture, and the fastest encode source cannot blend at diff --git a/packaging/debian/build-gamescope-deb.sh b/packaging/debian/build-gamescope-deb.sh new file mode 100755 index 00000000..2c02b285 --- /dev/null +++ b/packaging/debian/build-gamescope-deb.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Package an already-built punktfunk-gamescope binary as a .deb, for the Gitea apt registry. +# +# Counterpart to ../gamescope/build-gamescope-rpm.sh, and the same argument: the binary is a +# ~10-minute meson build of an unrelated tree that CI does once and caches, so this repacks rather +# than rebuilds. The Arch package (../gamescope/PKGBUILD) is the one recipe that builds from source, +# because that is what makepkg is for. +# +# Installed as /usr/bin/punktfunk-gamescope — it does NOT replace the distro's gamescope, and does +# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary +# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope). +# +# Usage: +# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \ +# --binary gs-cache/punktfunk-gamescope [--arch amd64] +# Output: dist/punktfunk-gamescope__.deb +set -euo pipefail + +BINARY="" +DEB_ARCH="" +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="${2:?--binary needs a path}"; shift 2 ;; + --arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; } +[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; } + +PKG="punktfunk-gamescope" +ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOTDIR" + +DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}" + +# The marker is the host's whole capability probe: a binary that lost the patches installs fine and +# then silently streams SDR, cursorless, at a 60 Hz-advertising session. Refuse to package it. +BANNER="$("$BINARY" --version 2>&1 | head -1)" +case "$BANNER" in + *'+pfhdr'*) ;; + *) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2 + echo " banner: $BANNER" >&2 + exit 1 ;; +esac + +# Derive the version from the binary when the caller did not pass one — it is the only source that +# cannot drift from what is actually in the package. +if [ -z "${VERSION:-}" ]; then + UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)" + PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')" + [ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; } + VERSION="${UPSTREAM}.${PFHDR}" +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +# mktemp gives 0700; the package root has to be world-readable or `dpkg-deb -c` shows the tree as +# root-only and some tooling refuses it. +chmod 0755 "$STAGE" +install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope" +mkdir -p "$STAGE/DEBIAN" + +# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes +# the package honest about the Ubuntu release it was compiled on: gamescope links a broad set +# (wlroots, SDL, libliftoff, vulkan, xwayland's libs), and hand-listing them would rot. +DEPS="" +if command -v dpkg-shlibdeps >/dev/null 2>&1; then + # dpkg-shlibdeps insists on running from a package root with a debian/ dir. + mkdir -p "$STAGE/debian" + : > "$STAGE/debian/control" + ( cd "$STAGE" && dpkg-shlibdeps -O --ignore-missing-info usr/bin/punktfunk-gamescope 2>/dev/null ) \ + > "$STAGE/.shlibdeps" || true + DEPS="$(sed -n 's/^shlibs:Depends=//p' "$STAGE/.shlibdeps" | head -1)" + rm -rf "$STAGE/debian" "$STAGE/.shlibdeps" +fi +[ -n "$DEPS" ] || echo "WARNING: dpkg-shlibdeps produced no Depends — packaging without them" >&2 + +{ + echo "Package: $PKG" + echo "Version: $VERSION" + echo "Architecture: $DEB_ARCH" + echo "Maintainer: unom " + echo "Section: utils" + echo "Priority: optional" + [ -n "$DEPS" ] && echo "Depends: $DEPS" + # Not a hard dependency in either direction: the host works without this binary (SDR, + # host-composited cursor), and someone may want the binary for their own capture consumer. + echo "Recommends: punktfunk-host" + echo "Homepage: https://git.unom.io/unom/punktfunk" + echo "Description: gamescope with punktfunk's PipeWire capture patches" + echo " gamescope built from the upstream revision punktfunk pins, plus the patches in" + echo " packaging/gamescope/patches:" + echo " ." + echo " * 10-bit BT.2020/PQ capture formats, so an HDR game reaches a capture consumer as HDR" + echo " instead of pre-tonemapped SDR." + echo " * --pipewire-composite-cursor: the pointer is painted into the capture stream, so a" + echo " consumer with no cursor of its own gets one and the host stops blending one in." + echo " * A headless session advertises its real mode and refresh rates (and" + echo " --custom-refresh-rates), so Steam and games see the resolution and refresh the stream" + echo " actually runs at instead of an unnamed 60 Hz panel." + echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted" + echo " into the capture stream, so the fps/stats readout is visible remotely." + echo " ." + echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched." +} > "$STAGE/DEBIAN/control" + +mkdir -p dist +OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb" +dpkg-deb --build --root-owner-group "$STAGE" "$OUT" +echo "==> wrote $OUT" +echo " banner: $BANNER" diff --git a/packaging/gamescope/build-gamescope-rpm.sh b/packaging/gamescope/build-gamescope-rpm.sh new file mode 100755 index 00000000..6d7f4df5 --- /dev/null +++ b/packaging/gamescope/build-gamescope-rpm.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Package an already-built punktfunk-gamescope binary as an RPM, for the Gitea RPM registry. +# +# WHY this exists: before it, the only ways to get punktfunk-gamescope were the Bazzite/Atomic +# sysext, the Arch package, the SteamOS installer, a NixOS option — or building gamescope from +# source yourself. A traditional Fedora-family box (Nobara, plain Fedora, Nobara-derived HTPCs) +# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming +# a session that told every game the display was 60 Hz. +# +# The binary is NOT built here; CI builds it once per Fedora major and caches it +# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats +# rebuilding. +# +# Usage: +# bash packaging/gamescope/build-gamescope-rpm.sh \ +# --binary gs-cache/punktfunk-gamescope \ +# [--version 3.16.25] [--release 1] [--outdir dist] +# +# Output: /punktfunk-gamescope--..rpm +set -euo pipefail + +BINARY="" +# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the +# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike. +VERSION="" +RELEASE="1" +OUTDIR="dist" + +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="${2:?--binary needs a path}"; shift 2 ;; + --version) VERSION="${2:?--version needs a value}"; shift 2 ;; + --release) RELEASE="${2:?--release needs a value}"; shift 2 ;; + --outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; } +[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; } + +ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOTDIR" + +# Derive the version from the binary itself when not told: it is the only source that cannot drift +# from what is actually being packaged. `gamescope version 3.16.25-1-g8c676c3+pfhdr4 (gcc …)` → +# `3.16.25` + the marker. RPM versions may not contain `-`, hence the trailing `.pfhdrN` form. +BANNER="$("$BINARY" --version 2>&1 | head -1)" +case "$BANNER" in + *'+pfhdr'*) ;; + *) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2 + echo " banner: $BANNER" >&2 + exit 1 ;; +esac +PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')" +if [ -z "$VERSION" ]; then + UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)" + [ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; } + VERSION="${UPSTREAM}.${PFHDR}" +fi + +echo "==> packaging $BINARY as punktfunk-gamescope-${VERSION}-${RELEASE}" +echo " banner: $BANNER" + +TOP="$(mktemp -d)" +trap 'rm -rf "$TOP"' EXIT +mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS} +install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope" + +mkdir -p "$OUTDIR" +rpmbuild \ + --define "_topdir $TOP" \ + --define "pf_version $VERSION" \ + --define "pf_release $RELEASE" \ + -bb packaging/gamescope/punktfunk-gamescope.spec + +find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} "$OUTDIR/" \; +echo "==> wrote $(find "$OUTDIR" -name 'punktfunk-gamescope-*.rpm' -newer "$TOP" -print -quit 2>/dev/null || echo "$OUTDIR"/punktfunk-gamescope-*.rpm)" diff --git a/packaging/gamescope/punktfunk-gamescope.spec b/packaging/gamescope/punktfunk-gamescope.spec new file mode 100644 index 00000000..bfbe163d --- /dev/null +++ b/packaging/gamescope/punktfunk-gamescope.spec @@ -0,0 +1,78 @@ +# punktfunk-gamescope — gamescope carrying punktfunk's capture patches, installed under its own +# name so it sits BESIDE the distro's gamescope (Game Mode keeps using that one). +# +# This spec REPACKS a binary that was already built, rather than building gamescope itself: the +# build is a ~10-minute meson compile of an unrelated tree that CI already does once per Fedora +# major and caches (.gitea/workflows/rpm.yml). Rebuilding it inside rpmbuild would double that for +# no gain, and would need network access in the build root to fetch the upstream tree. +# +# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because +# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects. +# +# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary +Name: punktfunk-gamescope +Version: %{pf_version} +Release: %{pf_release}%{?dist} +Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor, overlay, virtual-display modes) + +# gamescope is BSD-2-Clause; our patches are contributed under the same terms. +License: BSD-2-Clause +URL: https://git.unom.io/unom/punktfunk +Source0: punktfunk-gamescope + +# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and +# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the +# sessions punktfunk-host starts itself resolve this one (PUNKTFUNK_GAMESCOPE_BIN > +# punktfunk-gamescope > gamescope). +# +# The runtime library Requires are auto-generated by rpmbuild from the binary's ELF NEEDED entries, +# which is exactly right here: this binary is soname-coupled to the Fedora major it was compiled +# on, and the generated Requires are what stop it installing on the wrong one. +Recommends: punktfunk-host + +# Nothing is compiled here, so there is no debuginfo to extract; without this rpmbuild fails +# looking for sources it was never given. +%global debug_package %{nil} + +%description +gamescope built from the upstream revision punktfunk pins, plus the patches in +packaging/gamescope/patches: + + * 10-bit BT.2020/PQ capture formats on the PipeWire node, so an HDR game reaches a capture + consumer as HDR instead of pre-tonemapped SDR. + * --pipewire-composite-cursor: paint the pointer into the capture stream, so a consumer with no + cursor of its own gets one — and the host stops blending one in, which frees the encoder's + fastest zero-copy source. + * A headless session advertises its real mode and refresh rates (and --custom-refresh-rates), so + Steam and games see the resolution and refresh the stream actually runs at instead of an + unnamed 60 Hz panel. + * --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture + stream, so the fps/stats readout is visible to someone watching remotely. + +Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched. + +%prep +# Nothing to unpack: Source0 IS the binary. + +%build +# Nothing to build — see the header. + +%install +install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope + +%check +# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost +# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it. +# +# Executed in the build root, which is the same container the binary was compiled in — if that ever +# stops being true this check is the thing that notices. +%{buildroot}%{_bindir}/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' || { + echo "punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take" >&2 + exit 1 +} + +%files +%{_bindir}/punktfunk-gamescope + +%changelog +# Generated per build; see the git history for the patch set's own changes. -- 2.54.0