A gamescope session told every game its display was 60 Hz — and Fedora had no way to install the build that knows better #120

Merged
enricobuehler merged 3 commits from worktree-gamescope-virtual-display into main 2026-08-08 13:41:33 +00:00
Owner

Field report, 2026-08-08, Nobara, host 0.24.0, client an Odin 2 Portal at 1920x1080@120: "when I go into gamescope mode, it still shows only 60hz… I have automatically set resolution off so that if there were more options it would show up", plus no fps/stats overlay and no mouse cursor.

All three come from the same place, and none of them was a misconfiguration.

Why the refresh was wrong

CHeadlessConnector returns an empty span from both GetModes() and GetValidDynamicRefreshRates(), and reports GAMESCOPE_SCREEN_TYPE_INTERNAL. Two things follow, and they are the reporter's two complaints:

  • update_mode_atoms() takes the internal branch and deletes GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL — so there is no resolution list. Turning off "Set Resolution: Automatic" could never have revealed one.
  • 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 runs at. With --nested-refresh absent that is Init()'s 60 Hz default.

Same in master, 3.16.25 and 3.16.23.2. And it is not cosmetic: vblankmanager paces to the same number, so the session genuinely composited at 60 while the wire ran at 120 — the client's own fps counter kept reading 120 because our encode loop repeats the held frame. The reporter's in-game counter was the honest one; ours was not.

Why he wasn't on the patched build

He couldn't be. punktfunk-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 compile gamescope from source. A traditional Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from — ran stock gamescope by default. CI already builds the binary on Fedora; it was just never packaged.

What this does

0003-headless-advertise-the-virtual-display-s-mode-and-re.patch — real GetModes() + GetValidDynamicRefreshRates() from the resolved -W/-H/-r, report EXTERNAL so the mode list is published, and add --custom-refresh-rates. gamescope-session-plus has gated CUSTOM_REFRESH_RATES on that flag existing for years; no upstream gamescope ever had it, so the env var it plumbs was a no-op everywhere — including for us.

Blast radius of the EXTERNAL flip was checked rather than assumed: the vblank red-zone branch is arithmetically identical (min(rz, rz*hz) is rz), cv_touch_external_display_trackpad defaults off, and the mura lookup is a null-array index. Only the two intended effects change.

0004-pipewire-optionally-composite-the-external-overlay-i.patch — the fps/stats overlay is mangoapp, which gamescope draws as an external overlay, a layer paint_pipewire has never referenced on any version. Now behind --pipewire-composite-external-overlay, off by default like the cursor flag. Its commit id joins the repaint test: the numbers change precisely while the picture behind them is static, which is the case the existing test skips.

Marker moves to +pfhdr4, and the marker patch is renumbered last so the banner is stamped after the capabilities it advertises.

Host — passes both flags at their patch levels, plus PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 so the in-session menu can offer real choices.

And warn_if_mode_lost(). --nested-refresh 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 it, one that sets GAMESCOPECMD outright skips the whole builder. When that happens the stream still runs, still looks right, and the client's fps counter still reads correctly, while the game underneath is pinned to 60. Nothing said so. It warns rather than refusing: verify_managed_spawn_flags refuses because its retry re-plans, but a relaunch here would hand the session the same environment and lose the mode identically, so refusing would only loop.

I also corrected the comment above the launch env, which claimed CUSTOM_REFRESH_RATES "generates the mode the session ADVERTISES … what makes games see the real refresh". Believing that is why the real lever went unexamined.

Packagingpunktfunk-gamescope.spec + build-gamescope-rpm.sh, and build-gamescope-deb.sh. Both repack the binary CI already builds (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. Version is derived from the binary's own +pfhdrN banner — the only source that cannot drift from what is in the package — and both refuse a binary without the marker, since that marker is the host's entire capability probe.

Neither package Provides nor 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.

On the third complaint

The missing cursor is not fixed here, and the patched build would not have fixed it either. --pipewire-composite-cursor gates on ShouldDrawCursor() and calls MouseCursor::paint, which early-returns on wlserver.bCursorHidden — the same state checkSuspension() (the --hide-cursor-delay 3000 timer) sets and publishes as GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, which our XFixes reader already honours. Same hide, different painter. His log proves the plumbing came up healthy. Worth a separate look at whether a streamed session should pass a longer hide delay.

Verification

  • The 5-patch series git ams cleanly onto the pinned 8c676c39 from a fresh checkout, twice.
  • Both new C++ functions extracted verbatim from the patched tree and compiled -Wall -Wextra -std=c++23 against stubs, with behavioural assertions: rate-list parsing (spaced, unsorted, duplicate, junk), and mode publication (sorting, dedup, the running rate always present, re-entrancy, zero rejected).
  • Linux: cargo check --all-targets, clippy -D warnings, 133 tests (2 new), cargo fmt --check.
  • 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.

Not verified, and worth knowing before merge: no real gamescope compile happened — the box I had lacks meson/ninja/glslc and sudo — so CI's per-Fedora-major leg is the first actual build of these patches, and the .spec has never run.

Unrelated pre-existing failure: punktfunk-host hooks::tests::prep_runs_do_in_order_and_undo_in_reverse fails at hooks.rs:1187. Reproduced on unmodified 8f9c7287 before attributing it here; worth separate triage.

Field report, 2026-08-08, Nobara, host 0.24.0, client an Odin 2 Portal at 1920x1080@120: *"when I go into gamescope mode, it still shows only 60hz… I have automatically set resolution off so that if there were more options it would show up"*, plus no fps/stats overlay and no mouse cursor. All three come from the same place, and none of them was a misconfiguration. ## Why the refresh was wrong `CHeadlessConnector` returns an **empty span** from both `GetModes()` and `GetValidDynamicRefreshRates()`, and reports `GAMESCOPE_SCREEN_TYPE_INTERNAL`. Two things follow, and they are the reporter's two complaints: - `update_mode_atoms()` takes the internal branch and **deletes** `GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL` — so there is no resolution list. Turning off "Set Resolution: Automatic" could never have revealed one. - `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 runs at. With `--nested-refresh` absent that is `Init()`'s **60 Hz default**. Same in `master`, `3.16.25` and `3.16.23.2`. And it is not cosmetic: `vblankmanager` paces to the same number, so the session genuinely composited at 60 while the wire ran at 120 — the client's own fps counter kept reading 120 because our encode loop repeats the held frame. The reporter's in-game counter was the honest one; ours was not. ## Why he wasn't on the patched build He couldn't be. `punktfunk-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 compile gamescope from source. A traditional Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from — ran stock gamescope by default. **CI already builds the binary on Fedora; it was just never packaged.** ## What this does **`0003-headless-advertise-the-virtual-display-s-mode-and-re.patch`** — real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `EXTERNAL` so the mode list is published, and add `--custom-refresh-rates`. `gamescope-session-plus` has gated `CUSTOM_REFRESH_RATES` on that flag existing for years; no upstream gamescope ever had it, so the env var it plumbs was a no-op everywhere — including for us. Blast radius of the `EXTERNAL` flip was checked rather than assumed: the vblank red-zone branch is arithmetically identical (`min(rz, rz*hz)` is `rz`), `cv_touch_external_display_trackpad` defaults off, and the mura lookup is a null-array index. Only the two intended effects change. **`0004-pipewire-optionally-composite-the-external-overlay-i.patch`** — the fps/stats overlay is mangoapp, which gamescope draws as an *external overlay*, a layer `paint_pipewire` has never referenced on any version. Now behind `--pipewire-composite-external-overlay`, off by default like the cursor flag. Its commit id joins the repaint test: the numbers change precisely while the picture behind them is static, which is the case the existing test skips. Marker moves to `+pfhdr4`, and the marker patch is renumbered last so the banner is stamped after the capabilities it advertises. **Host** — passes both flags at their patch levels, plus `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` so the in-session menu can offer real choices. And `warn_if_mode_lost()`. `--nested-refresh` 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 it, one that sets `GAMESCOPECMD` outright skips the whole builder. When that happens the stream still runs, still looks right, and the client's fps counter still reads correctly, while the game underneath is pinned to 60. Nothing said so. It **warns** rather than refusing: `verify_managed_spawn_flags` refuses because its retry re-plans, but a relaunch here would hand the session the same environment and lose the mode identically, so refusing would only loop. I also corrected the comment above the launch env, which claimed `CUSTOM_REFRESH_RATES` "generates the mode the session ADVERTISES … what makes games see the real refresh". Believing that is why the real lever went unexamined. **Packaging** — `punktfunk-gamescope.spec` + `build-gamescope-rpm.sh`, and `build-gamescope-deb.sh`. Both repack the binary CI already builds (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. Version is derived from the binary's own `+pfhdrN` banner — the only source that cannot drift from what is in the package — and both refuse a binary without the marker, since that marker is the host's entire capability probe. Neither package `Provides` nor `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. ## On the third complaint The missing cursor is **not** fixed here, and the patched build would not have fixed it either. `--pipewire-composite-cursor` gates on `ShouldDrawCursor()` and calls `MouseCursor::paint`, which early-returns on `wlserver.bCursorHidden` — the same state `checkSuspension()` (the `--hide-cursor-delay 3000` timer) sets and publishes as `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`, which our XFixes reader already honours. Same hide, different painter. His log proves the plumbing came up healthy. Worth a separate look at whether a streamed session should pass a longer hide delay. ## Verification - The 5-patch series `git am`s cleanly onto the pinned `8c676c39` from a fresh checkout, twice. - Both new C++ functions extracted **verbatim** from the patched tree and compiled `-Wall -Wextra -std=c++23` against stubs, with behavioural assertions: rate-list parsing (spaced, unsorted, duplicate, junk), and mode publication (sorting, dedup, the running rate always present, re-entrancy, zero rejected). - Linux: `cargo check --all-targets`, `clippy -D warnings`, 133 tests (2 new), `cargo fmt --check`. - `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. **Not verified, and worth knowing before merge:** no real gamescope compile happened — the box I had lacks meson/ninja/glslc and sudo — so CI's per-Fedora-major leg is the first actual build of these patches, and the `.spec` has never run. **Unrelated pre-existing failure:** `punktfunk-host` `hooks::tests::prep_runs_do_in_order_and_undo_in_reverse` fails at `hooks.rs:1187`. Reproduced on unmodified `8f9c7287` before attributing it here; worth separate triage.
enricobuehler added 3 commits 2026-08-08 13:40:17 +00:00
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.
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.
feat(packaging): ship punktfunk-gamescope on RPM and apt too
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 26s
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m3s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m44s
android / android (pull_request) Successful in 4m50s
nix / flake (pull_request) Failing after 16m12s
eb8c943572
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.
enricobuehler merged commit 2a1c968a0e into main 2026-08-08 13:41:33 +00:00
enricobuehler deleted branch worktree-gamescope-virtual-display 2026-08-08 13:41:35 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#120