fix(gamescope): offer the refresh-rate set on the SteamOS path #466

Merged
enricobuehler merged 2 commits from fix/gamescope-steamos-refresh-rates into main 2026-08-30 23:39:17 +00:00
Owner

Two findings from triaging a CachyOS field report, verified on glass on a CachyOS VM with
gamescope-session-cachyos 1.1.6-1 and host 0.34.0-0.00016186.

The report

CachyOS /usr/lib/steamos/gamescope-session executes gamescope directly and Punktfunk isn't
intercepting that Managed-session launch. I installed punktfunk-gamescope after successfully
using my system's stock gamescope but without HDR.

The premise is wrong, and the takeover is fine

CachyOS's script ends in a bare exec gamescope (line 245 of 1.1.6), under
ExecStart=/usr/lib/steamos/gamescope-session. A bare name is exactly what the SteamOS PATH shim
intercepts, and nothing in that script hardcodes /usr/bin/gamescope.

Reproduced end to end. The host logged:

gamescope (SteamOS): took over gamescope-session.target headless at the client's mode
  node_id=64 w=5120 h=1440 hz=240

The running compositor was /usr/bin/punktfunk-gamescope, and HDR came up on both sides —
SessionPlan { bit_depth: 10, hdr: true }, NVENC at NV_ENC_BUFFER_FORMAT_ABGR10, and gamescope's
own negotiated a 10-bit stream — the composite is encoded as BT.2020 + PQ (HDR10).

Commit 2 — the reporter's actual cause is a documentation gap

gamescope_bin() and gamescope_patch_level() are OnceLocks, probed once per host process
(gamescope/discovery.rs). That is deliberate: a session's bit depth is irrevocable and has to be
settled before the display exists. The consequence is that installing punktfunk-gamescope under a
running host leaves the cached patch level at 0 for that process's whole life.

Nothing complains. hdr_args() returns empty, so verify_managed_spawn_flags has nothing to check
and passes. The box keeps streaming, in SDR, and the single line explaining it scrolled past at
startup. FLAGS_LOST is a one-way per-process latch with the same shape.

docs/gamescope.md listed the install command for eight platforms and never said to restart the
host. Fixed, with the two log lines that distinguish the states.

Commit 1 — the real bug found while reproducing it

write_steamos_dropin builds PF_HDR_ARGS from hdr_args + cursor_args only. It never adds
refresh_rate_args, so no SteamOS-style session has ever received --custom-refresh-rates.
Confirmed against the running argv on the test box.

The two gamescope-session-plus paths do deliver the set, but by a different mechanism — they hand
CUSTOM_REFRESH_RATES to the script and let it build the flag (launch_session's
--setenv=CUSTOM_REFRESH_RATES, and the box-session set-environment). Valve's script is not
session-plus and has never read that variable, and the PATH shim forwards only PF_HDR_ARGS. So the
flag fell through the one gap between the two designs.

Symptom on a Deck or any SteamOS-shaped box: Steam's in-session display settings show a single
refresh entry and no resolution list, and games pace themselves to that one number.

The fix chains refresh_rate_args(mode.refresh_hz.max(1)) into that drop-in — the session's own
mode, not the frame-limited PF_HZ that feeds -r, matching the game / offered split
launch_session already makes.

Why the list formatting moved into a pure function

Both managed paths interpolate the rate list into an unquoted ${PF_HDR_ARGS} that the shim's
shell word-splits. "Contains no whitespace" is therefore a correctness property of that string, not
a formatting detail — a space would split one flag into two argv entries. refresh_rate_list is
split out so that invariant has a test.

Verification

  • scripts/xcheck.sh linux clippy — clean (it passes --all-targets, so the test module compiled).
  • cargo fmt --all -- --check — clean.
  • cargo test -p pf-vdisplay --lib on a real Linux box — 275 passed, 0 failed, 5 ignored, the
    new test among them.

Not fixed here

plan_bind / session_script read SESSION_PLUS_BIN only, so the /usr/bin/gamescope bind lever
has no SteamOS-path equivalent. Harmless on CachyOS today, since its script honours PATH. Left
alone rather than armed speculatively.

Three traps that are not this bug

Worth recording, because all three cost time during the reproduction and each looks like a takeover
failure:

  1. Steam's first-run cold start beats the retry budget. Eight attempts of
    first frame: no PipeWire frame within 10s … no buffers arrived ≈ 65 s; steamwebhelper -uimode=4
    came up 26 s after the host gave up. Presents as a black screen; the second connect is fine.
  2. An operator PUNKTFUNK_INPUT_BACKEND pin silently kills all input. Routing returns
    "gamescope", but the injector still reads the operator key, so it tries Mutter RemoteDesktop and
    dies on ServiceUnknown. Pinning the compositor is not sufficient.
  3. Steam re-applies its own resolution. gamescope started -W 5120 -H 1440, then
    Updating mode for xwayland server #0: 1920x1080@240 once Big Picture painted.
Two findings from triaging a CachyOS field report, verified on glass on a CachyOS VM with `gamescope-session-cachyos 1.1.6-1` and host `0.34.0-0.00016186`. ## The report > CachyOS `/usr/lib/steamos/gamescope-session` executes gamescope directly and Punktfunk isn't > intercepting that Managed-session launch. I installed `punktfunk-gamescope` after successfully > using my system's stock gamescope but without HDR. ## The premise is wrong, and the takeover is fine CachyOS's script ends in a **bare** `exec gamescope` (line 245 of 1.1.6), under `ExecStart=/usr/lib/steamos/gamescope-session`. A bare name is exactly what the SteamOS PATH shim intercepts, and nothing in that script hardcodes `/usr/bin/gamescope`. Reproduced end to end. The host logged: ``` gamescope (SteamOS): took over gamescope-session.target headless at the client's mode node_id=64 w=5120 h=1440 hz=240 ``` The running compositor was `/usr/bin/punktfunk-gamescope`, and HDR came up on both sides — `SessionPlan { bit_depth: 10, hdr: true }`, NVENC at `NV_ENC_BUFFER_FORMAT_ABGR10`, and gamescope's own `negotiated a 10-bit stream — the composite is encoded as BT.2020 + PQ (HDR10)`. ## Commit 2 — the reporter's actual cause is a documentation gap `gamescope_bin()` and `gamescope_patch_level()` are `OnceLock`s, probed once per host process (`gamescope/discovery.rs`). That is deliberate: a session's bit depth is irrevocable and has to be settled before the display exists. The consequence is that installing `punktfunk-gamescope` under a **running** host leaves the cached patch level at `0` for that process's whole life. Nothing complains. `hdr_args()` returns empty, so `verify_managed_spawn_flags` has nothing to check and passes. The box keeps streaming, in SDR, and the single line explaining it scrolled past at startup. `FLAGS_LOST` is a one-way per-process latch with the same shape. `docs/gamescope.md` listed the install command for eight platforms and never said to restart the host. Fixed, with the two log lines that distinguish the states. ## Commit 1 — the real bug found while reproducing it `write_steamos_dropin` builds `PF_HDR_ARGS` from `hdr_args` + `cursor_args` only. It never adds `refresh_rate_args`, so **no SteamOS-style session has ever received `--custom-refresh-rates`**. Confirmed against the running argv on the test box. The two `gamescope-session-plus` paths do deliver the set, but by a different mechanism — they hand `CUSTOM_REFRESH_RATES` to the *script* and let it build the flag (`launch_session`'s `--setenv=CUSTOM_REFRESH_RATES`, and the box-session `set-environment`). Valve's script is not session-plus and has never read that variable, and the PATH shim forwards only `PF_HDR_ARGS`. So the flag fell through the one gap between the two designs. Symptom on a Deck or any SteamOS-shaped box: Steam's in-session display settings show a single refresh entry and no resolution list, and games pace themselves to that one number. The fix chains `refresh_rate_args(mode.refresh_hz.max(1))` into that drop-in — the session's own mode, not the frame-limited `PF_HZ` that feeds `-r`, matching the `game` / `offered` split `launch_session` already makes. ### Why the list formatting moved into a pure function Both managed paths interpolate the rate list into an **unquoted** `${PF_HDR_ARGS}` that the shim's shell word-splits. "Contains no whitespace" is therefore a correctness property of that string, not a formatting detail — a space would split one flag into two argv entries. `refresh_rate_list` is split out so that invariant has a test. ## Verification - `scripts/xcheck.sh linux clippy` — clean (it passes `--all-targets`, so the test module compiled). - `cargo fmt --all -- --check` — clean. - `cargo test -p pf-vdisplay --lib` on a real Linux box — **275 passed, 0 failed, 5 ignored**, the new test among them. ## Not fixed here `plan_bind` / `session_script` read `SESSION_PLUS_BIN` only, so the `/usr/bin/gamescope` bind lever has no SteamOS-path equivalent. Harmless on CachyOS today, since its script honours `PATH`. Left alone rather than armed speculatively. ## Three traps that are not this bug Worth recording, because all three cost time during the reproduction and each looks like a takeover failure: 1. **Steam's first-run cold start beats the retry budget.** Eight attempts of `first frame: no PipeWire frame within 10s … no buffers arrived` ≈ 65 s; `steamwebhelper -uimode=4` came up 26 s *after* the host gave up. Presents as a black screen; the second connect is fine. 2. **An operator `PUNKTFUNK_INPUT_BACKEND` pin silently kills all input.** Routing returns `"gamescope"`, but the injector still reads the operator key, so it tries Mutter RemoteDesktop and dies on `ServiceUnknown`. Pinning the compositor is not sufficient. 3. **Steam re-applies its own resolution.** gamescope started `-W 5120 -H 1440`, then `Updating mode for xwayland server #0: 1920x1080@240` once Big Picture painted.
enricobuehler added 2 commits 2026-08-30 23:28:03 +00:00
A SteamOS-style session (Valve's /usr/lib/steamos/gamescope-session, as
CachyOS ships it) never got --custom-refresh-rates, so Steam's
in-session display menu showed one refresh entry and no resolutions,
and games paced themselves to that single number.

The two gamescope-session-plus paths hand the set to the SCRIPT as
CUSTOM_REFRESH_RATES and let it build the flag. Valve's script has
never read that variable, and the PATH shim forwards only
PF_HDR_ARGS - so the flag reached no SteamOS session at all.

Chain refresh_rate_args into that drop-in's PF_HDR_ARGS, keyed on the
session's own mode like launch_session's `offered`. Split the list
formatting into a pure refresh_rate_list so the no-whitespace
invariant that the unquoted ${PF_HDR_ARGS} depends on has a test.
docs(gamescope): say to restart the host after installing the build
android / android (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 18s
ci / rust (pull_request) Canceled after 2m42s
ci / rust-arm64 (pull_request) Canceled after 2m35s
ci / docs-site (pull_request) Canceled after 52s
ci / web (pull_request) Canceled after 1m2s
ci / docs-drift (pull_request) Canceled after 0s
0f7bc56588
A field reporter installed punktfunk-gamescope under a running host
and kept streaming SDR, with no error to explain it. The host probes
the gamescope binary once per process and caches the answer, because
a session's bit depth must be settled before the display exists - so
a build installed mid-run is never seen.

The install section listed a command for every distro and never
mentioned the restart. Add it, and name the two startup log lines
that tell the two states apart.
enricobuehler merged commit a774690a78 into main 2026-08-30 23:39:17 +00:00
enricobuehler deleted branch fix/gamescope-steamos-refresh-rates 2026-08-30 23:39:23 +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#466