Files
punktfunk/docs-site/content/docs/gamescope.md
T
enricobuehler 647e2b8891
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 1m30s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 11m12s
fix(pf-vdisplay): bind our wrapper over the hardcoded gamescope path, where GAMESCOPE_BIN cannot reach
Nobara's gamescope-session-plus HARDCODES an absolute gamescope path and never reads GAMESCOPE_BIN —
verified on the box: `grep GAMESCOPE_BIN` over the script returns nothing, and line 244 opens
`GAMESCOPECMD="/usr/bin/gamescope \`. So neither the wrapper nor a PATH shim can reach it, and the
managed session comes up as stock gamescope with none of our flags. `verify_managed_spawn_flags`
catches that and refuses HDR and the in-node cursor rather than streaming a session planned around
flags that never arrived — correct, but it leaves every Nobara-family box with no composited cursor
and no HDR, and the operator's only recourse is overwriting a distro-owned binary.

Bind it instead, inside the session unit's own mount namespace: the session gets our gamescope, and
nothing outside the unit changes — the distro still owns the file on disk.

⚠ The bind source is the WRAPPER, not the patched binary. Binding the binary would have been a
vacuous fix: the flags this whole mechanism exists to deliver are injected BY the wrapper, so a bound
binary arrives with no flags and `verify_managed_spawn_flags` refuses exactly as before. Binding the
wrapper reproduces what GAMESCOPE_BIN would have done had the script consulted it.

Applied only where the escape hatch is structurally absent, decided by READING the script rather than
by distro name: if `GAMESCOPE_BIN` appears anywhere in it, we keep the mechanism that already works
and take no mount namespace we don't need. Parsing is pure and unit-tested — the `GAMESCOPECMD+=`
appends and the `[ -z "$GAMESCOPECMD" ]` test must not be mistaken for the opening assignment, or the
bind would land over `$socket`. Three further refusals: the target must be a file; the resolved
source must be absolute and NOT the target (a fork-bomb guard — the wrapper execs the real binary, so
shadowing that same path would exec itself forever); and the preflight must pass.

Availability is PROVEN, not inferred, with a throwaway `systemd-run --user --wait --collect
--property=BindReadOnlyPaths=<the real value> -- /bin/true`. That matters: on .25 the bind fails with
`status=226/NAMESPACE` while `kernel.unprivileged_userns_clone=1` and `max_user_namespaces=29006` are
both permissive — AppArmor's `apparmor_restrict_unprivileged_userns` blocks it. A sysctl check would
have said "available" and armed a bind that cannot work. The payload is /bin/true, so the preflight
can never accidentally start a compositor.

Runtime backstop for anything the preflight cannot see: `ExecMainStatus == 226` disarms the bind and
relaunches plain, latching one-way per process.

The transient host-owned unit takes the setting via `systemd-run --property=`, which is atomic with
the start and leaves nothing behind if the host dies. The box's own unit needs a drop-in, so it gets
`zz-punktfunk-bind.conf` on the TEMPLATE, sorted last so nothing can take the setting back out;
removal targets that one filename only — never the directory, never a glob — because the operator's
`10-headless.conf` next to it is what lets game mode start on that box at all.
2026-08-09 23:11:00 +02:00

282 lines
18 KiB
Markdown

---
title: Steam / gamescope
description: Configure a gamescope/Steam host — how the host gets a gamescope, session following, and limits.
---
gamescope is the compositor behind Steam **Gaming Mode** — the couch/handheld game UI on Bazzite,
SteamOS, or any distro running a gamescope session. The host **auto-detects** gamescope from your
live session, so you rarely need to set anything here. It also **follows a Gaming ↔ Desktop switch
mid-stream** — flip between Gaming Mode and the desktop with Steam's normal UI and the host
re-targets whatever's running without a reconnect.
This page covers the gamescope-specific choices. To get a host running on an appliance box, start
from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)](/docs/steamos-host).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## How the host gets a gamescope
There are three models; the host picks one per session, and you rarely have to. With **nothing
set**, a box that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **managed**;
the [Bazzite template](/docs/bazzite) ships with **attach** chosen instead.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is
rock-solid and disconnecting leaves the box where it was. When the box is **headless** (no
display connected) and the session is its own autologin unit, the host restarts it at the
**client's** resolution on a mismatch; a box driving a physical display — and any foreign or
bare gamescope — is streamed at its own mode.
- **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host
takes the box's gamescope session over and relaunches it **headless** at the *client's* exact
resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it —
restoring the box on idle after disconnect.
- **Bare spawn** (the default on a plain distro with no gamescope session infrastructure and no
gamescope already running, and the route a dedicated game launch takes unless you've forced
managed or attach) — the host starts its own headless gamescope per session at the client's mode
and runs the session's launch command (or `PUNKTFUNK_GAMESCOPE_APP`) inside it. Nothing on the
box is taken over, because there is nothing to take over.
### Nobara and other autologin display managers
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that
session is a display-manager autologin, it has to stop the **display manager** too, for the length
of the stream. That is a privileged operation, and the privilege is granted to one group.
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper
> runs for members of that group and for nobody else, so this one command is what authorizes it:
>
> ```sh
> sudo usermod -aG punktfunk "$USER" # then log out and back in
> ```
>
> Your package created the group at install time and put **nobody** in it, on purpose: it also
> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present
> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust.
> Skip it and nothing fails loudly. Every takeover degrades to mirroring the box's own session
> (below), which on a box whose panel is off reads as a black screen on every connect. The host
> checks this at startup on any box that will need the takeover and says so in its log; the
> symptom side is [Game Mode: black screen on
> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
How the takeover gets that privilege depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the
grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole
stream, which churns logind sessions and can starve the game.
- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin
start-limit-kills the display manager), so the host stops the display manager itself and
restarts it afterwards. The packages ship that privilege: a root helper
(`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch
package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically
when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the
`display-manager.service` symlink points at, the same class of local-seat operation these
distros already authorize for their own session switcher (Nobara's `os-session-select`).
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
box) — the host tries the plain verbs first, so the rule takes precedence:
```js
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "plasmalogin.service" &&
subject.user == "YOUR_USER") {
return polkit.Result.YES;
}
});
```
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
what happened; check the host log for "managed takeover unavailable"). That log line now quotes
the privileged path's own reason for refusing, so read it before changing anything: by far the
most common one is `not in the 'punktfunk' group`, which the group command above fixes and
neither a reinstall nor a polkit rule does. If the display-manager restart ever loses its
privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback.
**Lingering is required here**, and the host turns it on for you the first time it takes the box
over. Stopping the display manager ends your last login session, and without
`loginctl enable-linger` logind stops your `systemd --user` manager about ten seconds later —
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
ask; `loginctl disable-linger "$USER"` reverts it. (A host with no login session of its own turns
lingering on through the *same* helper, so a missing group grant surfaces here first — the log
says "enabling lingering failed" and then quotes the same reason.)
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## Stream the screen the box is already driving
There is a fourth thing you can ask for, and it isn't a gamescope model at all: **mirror the head
Gaming Mode is lighting**. A Gaming Mode gamescope is the DRM master of a real connector, so that
head is listed by `punktfunk-host list-monitors` and appears in the web console under **Virtual
displays → Streamed screen**. Pick it there, or pin it from
[`host.env`](/docs/configuration):
```sh
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
```
The host then attaches to the session's own composited output: nothing is stopped, nothing is
relaunched, no mode is imposed, and what you see is exactly what is on the TV. That is the
difference from **managed**, which deliberately takes the session over and blanks the panel.
Only the one head the session drives is listed — a nested or headless gamescope (including the
per-session ones the host spawns itself) has none of its own, so the picker is empty there. Full
details, including what the setting turns off, are in
[Stream a real monitor instead](/docs/virtual-displays#stream-a-real-monitor-instead).
## Session following
`PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the
backend in place, with no reconnect. It is **on by default** on Bazzite/SteamOS; set `0` to disable.
One host service covers both faces of the box: it streams Gaming Mode over gamescope and the desktop
over its own compositor, and re-targets whichever is live on each switch.
## Start the host
On an appliance box (Bazzite, SteamOS) the install guide already enables the host service for you. On
any other distro running a gamescope session, just start it — the host auto-detects the live
gamescope session and picks the model for it:
```sh
systemctl --user enable --now punktfunk-host
```
Which of the three it picked is logged per session:
```sh
journalctl --user -u punktfunk-host | grep 'gamescope sub-mode'
```
Then bring up [The Web Console](/docs/web-console) to arm pairing.
## gamescope knobs
Every gamescope setting — the three models above, `PUNKTFUNK_GAMESCOPE_NODE`, the bare-spawn flags
(`APP`, `SPLASH`, `STEAM`, `GRAB_CURSOR`), the binary override, the two HDR knobs, and
`PUNKTFUNK_SESSION_WATCH` — lives in the `host.env` reference, under *gamescope / session
following*: [Configuration](/docs/configuration). Leave them unset to auto-detect; set one only to
force a model.
Two are worth naming here, because the sections below turn on them:
- `PUNKTFUNK_GAMESCOPE_HDR` — on by default; see [HDR on gamescope](#hdr-on-gamescope).
- `PUNKTFUNK_GAMESCOPE_BIN` — forces one gamescope binary. Unset, the host prefers
`punktfunk-gamescope` on `PATH` and falls back to `gamescope`.
## HDR on gamescope
Games can render HDR on a headless gamescope today, but a stock gamescope's **capture** output is
8-bit SDR: its PipeWire node offers only 8-bit formats, and it tone-maps the composite down before
handing it over. So a stock setup streams SDR — correctly, including a correct SDR rendition of an
HDR game — and there is nothing to configure. This section is the gamescope half; the rest of the
chain, and what to check when a stream comes out SDR, is on [HDR](/docs/hdr#linux--gamescope).
To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch that adds the
10-bit BT.2020 PQ formats to that node (offered upstream as
[gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126)). It installs under its
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`.
- **NixOS** — `services.punktfunk.host.gamescopeHdr` (default `true`).
- **Anything else** — `bash packaging/gamescope/build-punktfunk-gamescope.sh` from the source tree.
HDR is attempted by default once the build is present (`PUNKTFUNK_GAMESCOPE_HDR=0` forces SDR).
**The build only reaches sessions the host starts itself** — managed, `PUNKTFUNK_GAMESCOPE_SESSION`,
or a bare spawn. In **attach** mode the running session is the box's own, started by the display
manager with the distro's `gamescope`, so it offers neither the 10-bit formats nor the in-node
cursor — but the host answers both questions by asking the *installed* binary, so an installed
`punktfunk-gamescope` makes it believe the attached session has them. Attach *plus* that build is
the combination to avoid: [HDR → Linux + gamescope](/docs/hdr#linux--gamescope) has what it costs
you and the two ways out.
The cursor is the half this page owns. The host leaves the pointer to the compositor whenever the
installed build can paint it (below) — so on an attached session, which can't, nothing draws it and
the stream has no cursor at all. Commenting
`PUNKTFUNK_GAMESCOPE_ATTACH=1` out of the [Bazzite template](/docs/bazzite) and letting the managed
default take over fixes that along with HDR. To stay on attach, point `PUNKTFUNK_GAMESCOPE_BIN` at
your distro's own `gamescope` (`/usr/bin/gamescope`) instead: the host goes back to compositing the
cursor itself, and — since the HDR answer comes from the same binary — stops attempting HDR too.
One thing to know beyond HDR itself: **the pointer rides on the same build.** When the compositor
paints the cursor into the capture node the host stops blending one in, which is also what frees the
session to take the encoder's fastest source — a front end with no blend stage. That is why
`punktfunk-gamescope` is worth installing even on a box where you never turn HDR on.
## Known limits
These apply to the **Gaming Mode (gamescope)** path only; the desktop path is unaffected.
- **gamescope 3.16.22 or newer is required; 3.16.23 or newer for the Steam overlay.** Below
3.16.22, headless capture can deadlock against PipeWire 1.6. Between 3.16.22 and 3.16.23 capture
works, but gamescope doesn't paint the Steam overlay (Shift+Tab / the Quick Access Menu) into its
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`.
- **Some distros' session script ignores `GAMESCOPE_BIN` entirely — the host works around it with a
bind mount.** Nobara's `gamescope-session-plus` never reads that variable and hardcodes
`/usr/bin/gamescope` outright, so neither the variable nor a `PATH` entry can point the session at
`punktfunk-gamescope`; every session ran the stock binary, which cost HDR *and* the in-stream
cursor. The host now detects that shape by reading the script, and binds its own wrapper over the
hardcoded path **inside the session unit's own mount namespace** — the session gets the patched
gamescope and the flags, and nothing outside that unit changes (the distro still owns
`/usr/bin/gamescope`; you do not need to overwrite it). You will see *"binding the punktfunk
wrapper over it"* in the log when this engages. It needs unprivileged user namespaces; on a box
without them the host logs that it cannot take a mount namespace and streams SDR with a
host-composited cursor, exactly as before. Boxes whose script does honour `GAMESCOPE_BIN`
(Bazzite, SteamOS) are untouched by any of this.
- **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
all. `punktfunk-gamescope` paints the pointer into the capture instead, so the host stops
redrawing it and the frame reaches the encoder untouched — but only in a session the host starts
itself, not in **attach** mode (see [HDR on gamescope](#hdr-on-gamescope) above).
- **Touch arrives as a single-finger pointer.** gamescope's virtual input device has no
touchscreen, so the host maps a client's touchscreen to an absolute pointer: taps click exactly
where you touch and drags work, but multi-touch gestures (pinch) aren't available in Gaming
Mode. The desktop path has full multi-touch, and the client's other two
[touch modes](/docs/input#touch-modes) — trackpad and direct pointer — are unaffected either way,
because they send ordinary mouse events.
- **Desktop (absolute) mouse mode is unavailable.** A client asking for it quietly stays captured
against a Gaming Mode session, and the [mouse-mode](/docs/input#mouse-modes) shortcut has nothing
to switch to.
- **There is no clipboard.** A gamescope session offers neither mechanism the host can read and
write a clipboard through, so [clipboard sharing](/docs/clipboard) does nothing in Gaming Mode,
even with both of its switches on.
- **HDR needs the Punktfunk gamescope build** — see [HDR on gamescope](#hdr-on-gamescope) above.
To stream the KDE Plasma desktop of a Steam box instead, see [KDE Plasma](/docs/kde). To bring up the
web console and pair a client, see [The Web Console](/docs/web-console).