Merge pull request 'fix(bazzite): the shipped template pinned ATTACH, so Game Mode mirrored the box's screen instead of giving the client its own display' (#189) from worktree-no-attach-default into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 1m35s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Failing after 3m35s
deb / build-publish-client-arm64 (push) Successful in 3m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 6m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 3m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
apple / screenshots (push) Successful in 6m7s
deb / build-publish-host (push) Successful in 6m41s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m9s
arch / build-publish (push) Successful in 11m45s
deb / build-publish (push) Successful in 12m33s
docker / deploy-docs (push) Failing after 7m31s
ci / rust (push) Successful in 18m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s

Reviewed-on: #189
This commit was merged in pull request #189.
This commit is contained in:
2026-08-13 09:24:21 +00:00
7 changed files with 69 additions and 46 deletions
+8 -2
View File
@@ -111,9 +111,15 @@ struct OperatorGamescope {
#[cfg(target_os = "linux")]
fn operator_gamescope() -> &'static OperatorGamescope {
OPERATOR_GAMESCOPE.get_or_init(|| {
// Explicit-off grammar, NOT a presence test. These two used to be `var_os(..).is_some()`,
// which read `PUNKTFUNK_GAMESCOPE_ATTACH=0` as ATTACH ON — the exact opposite of what the
// line says, and of every other knob on this host (`env_on` is shared for that reason).
// Anyone turning a shipped `=1` off does it the way the rest of the file works, and the
// rung this feeds outranks a dedicated game session, so a silent inversion here costs the
// client its own display for the whole stream.
let ov = with_env_lock(|| OperatorGamescope {
managed: std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
attach: std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
managed: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_MANAGED").unwrap_or(false),
attach: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_ATTACH").unwrap_or(false),
node: std::env::var("PUNKTFUNK_GAMESCOPE_NODE")
.ok()
.filter(|v| !v.is_empty()),
+26 -17
View File
@@ -161,21 +161,27 @@ The only settings that matter (GPU zero-copy is on by default):
```sh
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session — SDR, and no cursor (see below)
```
### Gaming Mode: attach vs managed
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
For Gaming Mode there are two models. The template forces **neither** — the host picks per connect,
and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
gamescope session on its own display, and the host attaches to whatever's live without ever
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
Desktop ↔ Game is rock-solid.
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
refresh — Game Mode on the virtual screen — restoring the box on idle.
- **Managed** (what you get by default here) — the host takes the box's gamescope over and
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
virtual screen — restoring the box on idle. This is the model that gives the client a display of
its **own**, and the only one under which a game launched from a client's library gets a
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
the display manager for the length of the stream, and without that grant it cannot.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
display, and the host attaches to whatever's live without ever tearing it down (on a headless
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
display connected it streams at the box's own mode). Switching Desktop ↔ Game is rock-solid, and
the cost is that a box with a screen attached serves the client a **mirror** of that screen
rather than its own display. Setting it also outranks a dedicated game session.
`=0` turns the attach override off, the same as removing the line.
Full treatment: [Steam / gamescope → How the host gets a
gamescope](/docs/gamescope#how-the-host-gets-a-gamescope).
@@ -227,14 +233,17 @@ These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is una
headless capture can deadlock; between the two, capture works but the Steam overlay (Shift+Tab /
the Quick Access Menu) is never painted into the captured node. Bazzite's current gamescope is
past both; this only bites if you've pinned an old one.
- **The template pins attach, and that costs you both the cursor and HDR.** The sysext ships the
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — and the
`host.env` template above sets `PUNKTFUNK_GAMESCOPE_ATTACH=1`, where the live session is
Bazzite's own stock gamescope. Comment that line out and let the managed default take over: you
get the compositor-drawn pointer and real HDR. To stay on attach instead, set
`PUNKTFUNK_GAMESCOPE_HDR=0` and `PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half
breaks: [gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
- **Forcing attach costs you the cursor, HDR and your own display.** The sysext ships the
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — under
`PUNKTFUNK_GAMESCOPE_ATTACH=1` the live session is Bazzite's own stock gamescope. The managed
default gets you the compositor-drawn pointer, real HDR and a display of the client's own. If you
deliberately stay on attach, also set `PUNKTFUNK_GAMESCOPE_HDR=0` and
`PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half breaks:
[gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
[HDR → Linux + gamescope](/docs/hdr#linux--gamescope) for the failed connect.
⚠ Older templates set `PUNKTFUNK_GAMESCOPE_ATTACH=1` for you — if you copied one, delete that
line from `~/.config/punktfunk/host.env`, because an upgrade never rewrites a file you already
have.
Those are the two that bite on Bazzite. The full set — touch, mouse modes, the clipboard — is on
[gamescope → Known limits](/docs/gamescope#known-limits).
+1 -1
View File
@@ -87,7 +87,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` · `0` *(unset = auto)* | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode — i.e. the client is served a **mirror**, not a display of its own. Setting this also outranks a dedicated game session. No template ships it set; `=0` is the same as leaving it out. |
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
+4 -4
View File
@@ -18,8 +18,8 @@ from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)]
## 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.
set** — which is what every shipped template does — a box that has gamescope session infrastructure
(Bazzite, SteamOS, Nobara) gets **managed**.
- **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;
@@ -208,8 +208,8 @@ 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
the stream has no cursor at all. Removing
`PUNKTFUNK_GAMESCOPE_ATTACH=1` from your `host.env` 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.
+7 -6
View File
@@ -76,12 +76,13 @@ available, and offers it. The attached session can't answer that negotiation, so
with no picture, the host latches an SDR downgrade for the rest of its life, and the next connect
streams — in SDR.
That is exactly what the [Bazzite](/docs/bazzite) template ships: it pins attach *and* the sysext
installs `punktfunk-gamescope`. Either comment `PUNKTFUNK_GAMESCOPE_ATTACH=1` out and let the managed
default take over (you get HDR and the compositor-drawn cursor), or stay on attach and set
`PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never happens. Staying on attach also leaves the
stream with no cursor; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that
half.
That combination bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope`
alongside a stock session gamescope. No template pins attach any more, so the managed default gets
you HDR and the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a
`host.env` you already have, so check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line.
If you deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never
happens. Staying on attach also leaves the stream with no cursor;
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
+1 -4
View File
@@ -261,9 +261,6 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# PUNKTFUNK_ZEROCOPY=0
#RUST_LOG=info
# Gaming Mode = ATTACH: the box owns its gamescope session; the host captures + follows it.
PUNKTFUNK_GAMESCOPE_ATTACH=1
```
**What each knob means and why these are the Bazzite defaults:**
@@ -272,7 +269,7 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1
|---|---|---|
| *(no compositor / no anchors)* | — | The host **auto-detects** the live session per connect (Gaming Mode gamescope vs the KDE desktop) and follows switches mid-stream; a `systemctl --user` service inherits the right `XDG_RUNTIME_DIR` and the host derives the bus itself. Pinning `PUNKTFUNK_COMPOSITOR` or hardcoding uid-1000 anchors only breaks this — leave them out. |
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | Gaming Mode model: the **box** owns its gamescope session; the host attaches to whatever's live and never tears it down. Swap for `PUNKTFUNK_GAMESCOPE_MANAGED=1` to have the host relaunch the gaming session headless at the **client's** exact mode instead (see the template's comments). |
| `PUNKTFUNK_GAMESCOPE_ATTACH` | *(unset)* | Gaming Mode model. Left unset the host picks one per connect, which on Bazzite is **managed**: it relaunches the gaming session headless at the **client's** exact mode, so the client gets a display of its own. Set `=1` to force **attach** instead — the box keeps its session and the client is served a mirror of the box's own screen at the box's mode (see the template's comments). |
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
+22 -12
View File
@@ -20,19 +20,29 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput
#
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
# host just ATTACHES to whatever's live and captures it; it never tears the session down. So
# switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
# mode — reconnecting drops you right back where you were. On a resolution mismatch the host
# restarts the box's own game-mode session at the CLIENT's resolution (a foreign/bare gamescope
# instead streams at its own mode).
PUNKTFUNK_GAMESCOPE_ATTACH=1
# GAME MODE: nothing here forces a model — the host picks one per connect. On this box (Bazzite
# ships gamescope-session-plus) that is the MANAGED model: on connect the host takes the box's
# gaming session over and relaunches it headless at the CLIENT's exact resolution, then restores
# the box's own session on a debounced idle. That is what gives the client a display of its OWN
# instead of a copy of the TV, and it is also what lets a game launched from a client's library get
# a dedicated session to itself.
#
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
# its OWN at the CLIENT's exact resolution; restores on a debounced idle). Client-mode-following, but
# it does not coexist with a box-owned game-mode session — pick one:
# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (and remove PUNKTFUNK_GAMESCOPE_ATTACH above)
# ⚠ The managed takeover has to stop the display manager for the length of the stream, and that is
# privileged: it works for members of the `punktfunk` group and nobody else. Join it once —
# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot
# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host
# checks at startup and says so in its log.
#
# Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever
# gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box
# stays in its current mode when you disconnect. The cost is that a box driving a physical display
# streams at the BOX's own mode, MIRRORED — the client does not get its own display — and this
# override outranks a dedicated game session too. Remove the line to go back to auto (`=0` also
# turns it off; any other value is on):
# PUNKTFUNK_GAMESCOPE_ATTACH=1
#
# Force the MANAGED model on a box where the auto-detection finds no session infrastructure:
# PUNKTFUNK_GAMESCOPE_MANAGED=1
#
# Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect). This is
# ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it: