Compare commits

..
Author SHA1 Message Date
enricobuehler 12f39e1967 fix(steamdeck): the updater stops dirtying the checkout it just pulled into
ci / web (pull_request) Successful in 57s
ci / bun-nix (pull_request) Successful in 25s
ci / rust-arm64 (pull_request) Successful in 2m56s
ci / docs-site (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 6m8s
`update.sh --pull` could abort with "Your local changes to the following files
would be overwritten by merge: web/bun.nix" — before a single service was
restarted — and the only way past it was to delete the file by hand.

The updater did it to itself. web/bun.nix is generated (bun2nix, a pure
function of web/bun.lock) but committed, because the Nix build fetches
node_modules only from it. The web step ran `bun install --frozen-lockfile`
without --ignore-scripts, so web's `postinstall` (`bun2nix -o bun.nix`)
rewrote that tracked file on every update. Harmless while the committed file
is in sync — but main carried a stale web/bun.nix from 1db8f763 to b79d90b4,
so any Deck updated in that window had it rewritten to the *correct* content
and has been sitting dirty ever since. The SDK step has always passed
--ignore-scripts, which is why only web/bun.nix ever went dirty.

Two changes, both in install.sh and update.sh:

  * the web install now passes --ignore-scripts and runs `bun run codegen`
    explicitly. web has two install lifecycle scripts and we want exactly one:
    `prepare` IS `bun run codegen` (orval + paraglide + the i18n check) and is
    required, since src/api/gen, src/paraglide and src/routeTree.gen.ts are
    gitignored and `prebuild` only re-runs orval; `postinstall` is the one that
    writes a committed file. Equivalent to the old behaviour minus bun2nix.

  * --pull restores web/bun.nix and sdk/bun.nix before pulling, which unsticks
    the installs already broken out there. Deliberately NOT `git reset --hard`:
    $SRC is the operator's own checkout and may carry real local work, so a
    still-dirty tree now fails with a message that names the files and the way
    out instead of git's raw abort. Discarding these two is provably lossless —
    regenerating them from the lockfiles is exactly what bun2nix does.

CI already gates the drift that made this visible (scripts/ci/check-bun-nix.sh,
ci.yml), so main cannot ship a stale bun.nix again.
2026-08-08 17:15:49 +02:00
enricobuehler 2a1c968a0e Merge pull request 'A gamescope session told every game its display was 60 Hz — and Fedora had no way to install the build that knows better' (#120) from worktree-gamescope-virtual-display into main
apple / swift (push) Successful in 1m37s
ci / web (push) Successful in 1m42s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 5m17s
deb / build-publish-client-arm64 (push) Successful in 1m48s
apple / screenshots (push) Successful in 6m27s
android / android (push) Successful in 9m56s
windows-host / package (push) Successful in 10m52s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m20s
arch / build-publish (push) Successful in 16m0s
windows-host / canary-manifest (push) Failing after 32s
deb / build-publish-host (push) Successful in 12m7s
ci / rust (push) Successful in 17m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
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 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m8s
docker / deploy-docs (push) Successful in 33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m51s
nix / flake (push) Failing after 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
Reviewed-on: #120
2026-08-08 13:41:30 +00:00
enricobuehler eb8c943572 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
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.
2026-08-08 15:36:19 +02:00
enricobuehler 102f550bba feat(host): use the new gamescope capabilities, and say so when the mode is lost
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.
2026-08-08 15:35:58 +02:00
enricobuehler 818531a26e feat(gamescope): a headless session now reports its own mode, and the perf overlay reaches the stream
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.
2026-08-08 15:35:37 +02:00
enricobuehler 608baf63be Merge pull request 'Post-sleep sessions still failed on 0.25.0 — the host was holding open the very device its recovery asks PnP to cycle' (#119) from worktree-vdisplay-reap-pnputil into main
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m46s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 26s
android / android (push) Successful in 6m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 4m27s
deb / build-publish-client-arm64 (push) Successful in 2m0s
deb / build-publish (push) Successful in 5m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m14s
windows-host / package (push) Canceled after 11m59s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #119
2026-08-08 13:29:09 +00:00
enricobuehler fba22c6c64 fix(host/vdisplay): the host no longer vetoes its own wake-from-sleep recovery — control handles close on retire
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 8m16s
ci / rust (pull_request) Successful in 9m59s
The control-device sharing contract was 'bare HANDLE copies, never
closed for the process lifetime': retired handles were deliberately kept
alive because the pinger/linger threads and the capture delivery
closures held raw copies whose soundness depended on no-close. The cost
surfaced in the 2026-08-08 field log: after a wake left the driver
hostless, every adapter reload came back REFUSED (Generic failure) —
and an open control handle is exactly what vetoes the PnP disable (and
can wedge the pnputil restart) the recovery leans on.
reset-pf-vdisplay.ps1 stops the whole host service precisely to get
those handles closed; the in-process recovery could not, because the
process could never close them.

Ownership is now Arc all the way out: ensure_device/device_handle/
control_device_handle hand out Arc<OwnedHandle> clones, every consumer
holds its clone across its IOCTLs (the capture closures each own one —
Arc<OwnedHandle> is Send+Sync, ending the isize smuggling), and
retiring drops only the manager's reference, so the handle CLOSES when
the last in-flight user drains. DeviceSlot::retired is gone. The
recovery path now releases the manager's reference at the first absent
sighting — the 3 s ABSENT_SETTLE doubles as the drain window — and
again before a not-ready-deadline reload, so the PnP cycle finally runs
against a device the host is no longer holding open.

The driver attaches no meaning to the control file closing (host-gone
is the IOCTL-liveness watchdog, EvtFileClose deliberately unhooked), so
the close has no driver-side side effects. Lock order note: RECOVERY →
device is now taken (the release hooks); the forbidden inverse still
never occurs — VdisplayDriver::open never reloads.
2026-08-08 13:37:05 +02:00
enricobuehler f242b2d2fc fix(host/vdisplay): a refused adapter reload now says WHY, and never targets a phantom devnode
Field log 2026-08-08 (0.25.0, wake from sleep): every session died on
'the adapter devnode could not be reloaded (Generic failure)' — the WMI
catch-all — because the REFUSED branch reported only the Disable
exception and threw away everything that would identify the failure
mode: why the pnputil /restart-device fallback ALSO failed (its exit
code — 3010 'needs a reboot' is its own diagnosis), what state the
devnode was in, and whether the right devnode was even targeted.

That last one is a real trap, not just missing telemetry: Get-PnpDevice
lists not-present PHANTOM devnodes (upgrade/reinstall leftovers), and
Select-Object -First 1 could hand every recovery attempt a phantom —
whose disable and restart both fail exactly like the field log — while
a live node sat unexamined. The selector now prefers present nodes (OK
before problem-state), and a phantom-only state gets a truthful
refusal: no reload can revive a devnode record whose device is gone;
only reinstalling re-creates it.

The REFUSED line now carries devnode counts, the chosen node's PnP
Status + ConfigManager problem code, and the restart exit code, so the
next field log decides between handle-veto, phantom, and problem-state
instead of reading 'Generic failure'. Decode pinned by test.
2026-08-08 13:22:05 +02:00
enricobuehler 975fef2048 fix(host/vdisplay): the ghost-monitor reap can no longer fail in silence
The reap that keeps departed virtual monitors from exhausting the IddCx
monitor-slot budget launched pnputil by BARE NAME — under the LocalSystem
service's PATH that can miss System32, SilentlyContinue swallowed the
miss, and the Rust side logged only when the count was positive: a reap
that removed nothing and a box with no ghosts were byte-identical
(silence). Ghosts then ratcheted up with every sleep cycle until
IOCTL_ADD wedged at 0x80070490 and every session black-screened — and
the wedge self-heal shipped in 0.25.0 retried an ADD behind a reap that
could never remove anything, which is exactly a persistent post-sleep
"no connection" surviving the b6acbd09 probe fix.

Same family and same cure as the adapter-reload path one function down:
resolve pnputil via $env:SystemRoot (a SYSTEM process must not trust
PATH anyway — a planted pnputil.exe would run elevated), pre-seed
$LASTEXITCODE to failure before every launch, and report found AND
removed unconditionally so "no ghosts" and "removed nothing" are
finally distinguishable in a field log. The report parse is split out
and pinned by tests like classify_reload_output.
2026-08-08 12:19:48 +02:00
22 changed files with 1419 additions and 163 deletions
+40
View File
@@ -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 }}
+34
View File
@@ -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.
+2 -1
View File
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
_keepalive: Box<dyn Send>,
}
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
// `Send + Sync` on their own). It is
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
+50
View File
@@ -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<u32>,
/// `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<u32>,
/// `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::<u32>().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<u32> {
let mut out: Vec<u32> = raw
.unwrap_or_default()
.split(',')
.filter_map(|s| s.trim().parse::<u32>().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 [
@@ -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<Vec<String>> {
/// 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<u32> {
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<u32> {
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<String>]) -> Vec<String> {
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::<Vec<_>>()
.join("/")
));
}
let rates: Vec<u32> = 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::<Vec<_>>().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::<Vec<_>>().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<String> {
/// 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<String> {
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 <list>` 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<String> {
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::<Vec<_>>()
.join(","),
]
}
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. 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<String> { 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.
@@ -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
@@ -299,16 +299,21 @@ struct Pinger {
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
/// the version handshake. Retired handles are deliberately kept alive — never closed — for the
/// process lifetime: the pinger/linger threads and every capturer's `ChannelBroker` hold BARE
/// `HANDLE` copies whose soundness contract is "never closed"; a retired handle only ever FAILS
/// IOCTLs, which every holder already tolerates. Reopens are rare (a driver restart), so the retained
/// list is bounded in practice.
/// the version handshake.
///
/// Ownership is `Arc` all the way out: every consumer — `acquire`'s IOCTL runs, the pinger/linger
/// threads, the capture layer's delivery closures — holds its OWN clone across its use, so retiring
/// here merely drops the manager's reference and the handle CLOSES when the last in-flight user
/// drains. That close is load-bearing, not housekeeping: an open control handle is exactly what
/// vetoes the PnP disable/restart the wake-from-sleep recovery leans on (field 2026-08-08 — every
/// reload REFUSED `Generic failure`; `reset-pf-vdisplay.ps1` stops the whole host service precisely
/// to get its handles closed, and Arc ownership buys the same release without dying). The previous
/// contract kept retired handles open for the process lifetime because bare `HANDLE` copies were
/// smuggled into threads and closures; those copies are gone, and nothing may rely on a dead
/// handle staying open again.
#[derive(Default)]
struct DeviceSlot {
current: Option<Arc<OwnedHandle>>,
/// Never dropped — see the type doc (bare-`HANDLE` holders rely on no-close).
retired: Vec<Arc<OwnedHandle>>,
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
/// races sessions this process still considers live and must not raze them.
opened_once: bool,
@@ -397,11 +402,6 @@ pub fn vdm() -> &'static VirtualDisplayManager {
.expect("VirtualDisplayManager used before a backend initialised it")
}
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: cached handles are never closed
/// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy
/// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a
/// capturer, which only exists on a monitor the manager created.
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
/// handshake-latched protocol version, opening the control device once if no session has
/// opened it yet this service run (the same open every session performs anyway) — so the
@@ -421,7 +421,13 @@ pub fn hw_cursor_capable() -> bool {
m.driver_proto.load(Ordering::Relaxed) >= 5
}
pub fn control_device_handle() -> Option<HANDLE> {
/// The live pf-vdisplay control device, for the IDD-push capturer's sealed-channel delivery
/// (`IOCTL_SET_FRAME_CHANNEL`) — an `Arc` clone the caller (and every closure it builds) holds for
/// as long as it may issue IOCTLs: the handle stays open while any holder lives and closes when the
/// last drains, which is what lets the wake-from-sleep recovery's PnP disable proceed once the
/// manager retires it (see [`DeviceSlot`]). `None` before the first backend open — impossible for a
/// capturer, which only exists on a monitor the manager created.
pub fn control_device_handle() -> Option<Arc<OwnedHandle>> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
@@ -497,17 +503,28 @@ fn is_device_gone(e: &anyhow::Error) -> bool {
GONE.contains(&w.code().0)
}
/// The transient raw `HANDLE` view of an Arc-held control device, for the backend IOCTL surface.
/// Sound only while the `Arc` it borrows from is held — which the borrow makes structural: every
/// use site necessarily has the owning clone alive across the call, so a concurrent retire (which
/// now really closes the handle once its users drain — see [`DeviceSlot`]) can never close it
/// mid-IOCTL.
fn dev_raw(dev: &OwnedHandle) -> HANDLE {
HANDLE(dev.as_raw_handle())
}
impl VirtualDisplayManager {
pub(crate) fn backend_name(&self) -> &'static str {
self.driver.name()
}
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens.
fn ensure_device(&self) -> Result<HANDLE> {
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens. Returns an
/// `Arc` clone the caller holds across every IOCTL it derives from it — a concurrent retire then
/// drops only the manager's reference and closes nothing under the caller (see [`DeviceSlot`]).
fn ensure_device(&self) -> Result<Arc<OwnedHandle>> {
let mut slot = self.device.lock().unwrap();
if let Some(d) = &slot.current {
return Ok(HANDLE(d.as_raw_handle()));
return Ok(d.clone());
}
let reap = !slot.opened_once;
claim_instance()?;
@@ -519,35 +536,33 @@ impl VirtualDisplayManager {
slot.opened_once = true;
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
self.driver_proto.store(driver_proto, Ordering::Relaxed);
let raw = HANDLE(handle.as_raw_handle());
slot.current = Some(Arc::new(handle));
let dev = Arc::new(handle);
slot.current = Some(dev.clone());
if !reap {
tracing::info!("virtual-display control device reopened (retired handle replaced)");
}
Ok(raw)
Ok(dev)
}
/// The live control handle for the pinger/linger threads. `None` before the first acquire opened
/// it, or between a retire and the next reopen.
fn device_handle(&self) -> Option<HANDLE> {
self.device
.lock()
.unwrap()
.current
.as_ref()
.map(|d| HANDLE(d.as_raw_handle()))
/// The live control device for the pinger/linger threads — an `Arc` clone the caller holds
/// across its IOCTLs. `None` before the first acquire opened it, or between a retire and the
/// next reopen.
fn device_handle(&self) -> Option<Arc<OwnedHandle>> {
self.device.lock().unwrap().current.clone()
}
/// Retire the cached control handle after a gone-classified IOCTL failure. The handle is retained
/// un-closed (see [`DeviceSlot`]); the next [`ensure_device`](Self::ensure_device) reopens the
/// (new) device interface and re-runs the version handshake.
/// Retire the cached control handle after a gone-classified IOCTL failure: drop the manager's
/// reference, so the handle CLOSES once the last in-flight user drains (see [`DeviceSlot`]) —
/// the release the wake-from-sleep recovery needs before it can cycle the adapter devnode. The
/// next [`ensure_device`](Self::ensure_device) reopens the (new) device interface and re-runs
/// the version handshake.
fn invalidate_device(&self, why: &anyhow::Error) {
let mut slot = self.device.lock().unwrap();
if let Some(cur) = slot.current.take() {
if slot.current.take().is_some() {
tracing::warn!(
"virtual-display control device retired — reopening on next use (cause: {why:#})"
"virtual-display control device retired — closes when its last user drains, \
reopening on next use (cause: {why:#})"
);
slot.retired.push(cur);
}
}
@@ -620,11 +635,11 @@ impl VirtualDisplayManager {
old_target,
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
);
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
// value `ensure_device()` returned above (cached handles are never closed — a dead one
// is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; the `dev`
// Arc `ensure_device()` returned above is held across this call, so the handle stays
// open even against a concurrent retire. `mon` was just removed from the map, so it
// is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev, &mut inner, mon) };
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
@@ -657,11 +672,11 @@ impl VirtualDisplayManager {
wudf_pid = mon.wudf_pid,
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
);
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value
// `ensure_device()` returned above (cached handles are never closed — a dead one is
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
// SAFETY: `teardown_removed` requires a valid control handle; the `dev` Arc
// `ensure_device()` returned above is held across this call, so the handle stays
// open even against a concurrent retire. `mon` was just removed from the map, so it
// is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev, &mut inner, mon) };
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
let _ = wait_target_departed(old_target, Duration::from_millis(400));
}
@@ -693,9 +708,10 @@ impl VirtualDisplayManager {
else {
unreachable!("just matched Active");
};
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
// waits inside run under the held `state` lock (this fn's discipline).
match unsafe { self.resize_in_place(dev, mon, mode) } {
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across
// this call (so the handle stays open); the CCD waits inside run under
// the held `state` lock (this fn's discipline).
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
Ok(()) => {
// Same join semantics as the re-arrival: +1 ref for the new
// (build-then-drop overlap) lease; `gen` untouched, so the old
@@ -734,10 +750,11 @@ impl VirtualDisplayManager {
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
unreachable!("just matched Active");
};
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across this call
// (so the handle stays open); `re_add` touches the live topology under the held
// `state` lock. `mon` is owned here (removed from the map).
let new_mon = match unsafe {
self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr)
self.re_add(dev_raw(&dev), &mut inner, slot, &mon, mode, client_hdr)
} {
ReAdd::Arrived(m) => *m,
ReAdd::RolledBack {
@@ -815,11 +832,11 @@ impl VirtualDisplayManager {
}
// The slot is empty: create a fresh monitor for it.
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle
// `ensure_device()` returned above (cached handles are never closed — a dead one is retired,
// kept alive; see `DeviceSlot`), and we hold the `state` lock.
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; the `dev` Arc
// `ensure_device()` returned above is held across this call (so the handle stays open even
// against a concurrent retire), and we hold the `state` lock.
let mon = match unsafe {
self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)
self.create_monitor(dev_raw(&dev), mode, slot, client_hdr, hw_cursor, &mut inner)
} {
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
@@ -831,9 +848,18 @@ impl VirtualDisplayManager {
tracing::info!(
"virtual-display control device reopened — retrying the monitor create"
);
// SAFETY: as above — `dev` is the handle the reopening `ensure_device` just
// returned, and the `state` lock is still held.
unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? }
// SAFETY: as above — the `dev` Arc the reopening `ensure_device` just returned is
// held across this call, and the `state` lock is still held.
unsafe {
self.create_monitor(
dev_raw(&dev),
mode,
slot,
client_hdr,
hw_cursor,
&mut inner,
)?
}
}
r => r?,
};
@@ -887,13 +913,12 @@ impl VirtualDisplayManager {
let mut warned = false;
while !stop_t.load(Ordering::Relaxed) {
if let Some(h) = vdm().device_handle() {
// SAFETY: `ping` requires `dev` to be a valid control handle. `h` is from
// `device_handle()` (the `Some` branch) — cached handles are NEVER closed for the
// process lifetime (a dead one is retired, kept alive; see `DeviceSlot`), so the
// handle stays valid for this call even if it was retired concurrently — at worst
// the IOCTL fails. The pinger thread only spins while the `&'static` manager
// singleton lives.
match unsafe { vdm().driver.ping(h) } {
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
// `device_handle()` is held across this call, so the handle stays open even if
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
// while the `&'static` manager singleton lives.
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
Ok(()) => warned = false,
Err(e) if is_device_gone(&e) => {
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
@@ -1897,12 +1922,11 @@ impl VirtualDisplayManager {
slot,
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
);
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev`
// is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some`
// checked above; cached handles are never closed — a dead one is retired, kept
// alive). `mon` was moved out of the map under the `state` lock, so it is
// exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev, &mut inner, mon) };
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; the
// `dev` Arc from `device_handle()` (the `Some` checked above) is held across
// this call, so the handle stays open. `mon` was moved out of the map under the
// `state` lock, so it is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
}
None => {
inner.slots.insert(
@@ -1980,10 +2004,10 @@ impl VirtualDisplayManager {
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
);
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
// `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()`
// (the `Some` checked above). `mon` was moved out of the map under the
// `state` lock, so it is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev, &mut inner, mon) };
// the `dev` Arc from `device_handle()` (the `Some` checked above) is held
// across this call, so the handle stays open. `mon` was moved out of the
// map under the `state` lock, so it is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
// acquire() Lingering-preempt settle).
thread::sleep(Duration::from_millis(400));
@@ -2051,11 +2075,12 @@ impl VirtualDisplayManager {
// its session. Lock order stays state → device (teardown's invalidate
// path), same as every other holder; the pinger takes only the device
// lock — no inversion.
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is
// from `self.device_handle()` (cached handles are never closed — a dead
// one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of
// the map under the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev, &mut g, mon) };
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
// Arc from `self.device_handle()` is held across this call, so the
// handle stays open (a concurrent retire drops only the manager's
// reference; see `DeviceSlot`). `mon` was moved out of the map under
// the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
}
}
})
@@ -2218,11 +2243,11 @@ impl VirtualDisplayManager {
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
inner.slots.remove(&k)
{
// SAFETY: `teardown_removed` needs a live control handle; `dev` is from
// `device_handle()` (cached handles are never closed — a dead one is retired, kept
// alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock,
// so it is exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev, &mut inner, mon) };
// SAFETY: `teardown_removed` needs a live control handle; the `dev` Arc from
// `device_handle()` is held across this call, so the handle stays open (see
// `DeviceSlot`). `mon` was moved out of the map under the `state` lock, so it is
// exclusively owned here — no aliasing.
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
released += 1;
}
}
@@ -100,14 +100,27 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
/// failure is logged and swallowed. Returns the number removed.
///
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
/// a box with no ghosts produced byte-identical logs (silence) — the same vacuous-signal family as
/// the `status=OK` trap [`reload_vdisplay_adapter`] answers — while ghosts ratcheted toward the
/// wedge with every sleep cycle.
fn reap_ghost_monitors() -> u32 {
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
// non-English box (unlike a .ps1 *file* read in the machine codepage).
//
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
// System32 (and a SYSTEM process must not trust PATH anyway — a planted `pnputil.exe` would run
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
Write-Output $n";
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
Write-Output ($g.Count.ToString() + ' ' + $n)";
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
// System32 — with a bare-name fallback.
let ps = std::env::var("SystemRoot")
@@ -125,17 +138,29 @@ fn reap_ghost_monitors() -> u32 {
.output()
{
Ok(o) => {
let n = String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<u32>()
.unwrap_or(0);
if n > 0 {
let raw = String::from_utf8_lossy(&o.stdout);
let Some((found, removed)) = parse_reap_output(&raw) else {
tracing::warn!(
reaped = n,
output = %raw.trim(),
"pf-vdisplay: ghost-monitor reap died before reporting — ghost nodes (if any) still pin IddCx monitor slots"
);
return 0;
};
if found == 0 {
tracing::info!("pf-vdisplay: no ghost (not-present) virtual-monitor nodes to reap");
} else if removed < found {
tracing::warn!(
found,
removed,
"pf-vdisplay: ghost-monitor reap could NOT remove every ghost node — the leftovers keep pinning IddCx monitor slots toward the 0x80070490 wedge"
);
} else {
tracing::warn!(
reaped = removed,
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
);
}
n
removed
}
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
@@ -144,6 +169,18 @@ fn reap_ghost_monitors() -> u32 {
}
}
/// Parse [`reap_ghost_monitors`]'s script output — `"<found> <removed>"`. Split out to be testable
/// without a box, like [`classify_reload_output`]: the field failure this answers was a reap whose
/// outcome could not be decoded from the log at all, so the decoding is worth pinning down. `None`
/// = the script died before reporting (callers treat that as "removed nothing", loudly).
fn parse_reap_output(out: &str) -> Option<(u32, u32)> {
let mut it = out.split_whitespace().map(str::parse::<u32>);
match (it.next(), it.next()) {
(Some(Ok(found)), Some(Ok(removed))) => Some((found, removed)),
_ => None,
}
}
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
@@ -178,6 +215,14 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
// reported tokens are ours, so parsing them is locale-invariant too.
//
// The selector prefers LIVE devnodes: `Get-PnpDevice` also lists not-present PHANTOMS (an
// upgrade/reinstall leftover), and the old `Select-Object -First 1` could hand every recovery
// attempt a phantom — whose disable AND restart both fail — while a live node sat unexamined.
// A phantom-only state gets its own truthful refusal: no reload lever can revive a devnode
// record whose device is GONE; only re-creating the node (reinstall) can. `Present` is the
// authoritative bit, with `Status -ne 'Unknown'` as the fallback should it read null; live
// `OK` nodes sort ahead of problem-state ones.
//
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
@@ -188,10 +233,19 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
// System32.
//
// The REFUSED line carries the evidence a field log needs to tell the failure modes apart
// (2026-08-08: a woken box logged only `REFUSED Generic failure` — the WMI catch-all — leaving
// handle-veto vs phantom vs problem-state undecidable): how many devnodes matched and how many
// are live, the chosen node's PnP Status + ConfigManager problem code, and the pnputil
// /restart-device exit code the old script threw away (3010 = needs a reboot, which is its own
// diagnosis).
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
$id = $ad.InstanceId; $err = ''; \
$all = @(Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' }); \
if ($all.Count -eq 0) { Write-Output 'ABSENT'; exit }; \
$live = @($all | Where-Object { $_.Present -or $_.Status -ne 'Unknown' } | Sort-Object { $_.Status -ne 'OK' }); \
if ($live.Count -eq 0) { Write-Output ('REFUSED only phantom (not-present) adapter devnodes remain (' + $all.Count + ') - the device node itself is gone and no reload can revive it; reinstalling the host re-creates it'); exit }; \
$ad = $live[0]; $id = $ad.InstanceId; $err = ''; \
try { \
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
@@ -201,9 +255,11 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
$rx = $LASTEXITCODE; \
if ($rx -eq 0) { Start-Sleep -Seconds 2; \
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; \
Write-Output ('REFUSED devnodes=' + $all.Count + ' live=' + $live.Count + ' status=' + $ad.Status + ' problem=' + $ad.ConfigManagerErrorCode + ' restart_exit=' + $rx + ' ' + $err) }";
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
@@ -1050,10 +1106,12 @@ const BRIEF_RETRY: Duration = Duration::from_secs(3);
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
/// on. The second caller through typically finds the interface already up and returns at once.
///
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
/// Taken ONLY by [`ensure_available`], which holds no manager lock. The lock order is one-way —
/// `RECOVERY` → `device`: the recovery's handle-release hooks (`invalidate_cached_device`, which
/// drops the manager's reference so the control handle can CLOSE before the PnP cycle) take the
/// `device` mutex while this is held. It must stay one-way: [`VdisplayDriver::open`] runs *inside*
/// that same `device` mutex, so if it could also take this lock the two orders would invert and
/// deadlock. It cannot — it never reloads.
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
@@ -1069,10 +1127,11 @@ pub fn ensure_available() -> Result<()> {
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
wait_for_interface(NOT_READY_GRACE, true)
};
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
// stack down and back up, so any control handle a previous session cached is dead by
// construction — retire it while we know that for certain, rather than leaving the next session
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
// A reload tore the driver stack down and back up, so any control handle cached MEANWHILE (a
// racing open during the arrival window) is dead by construction — retire it while we know
// that for certain, rather than leaving the next session to discover it by having an IOCTL
// fail. Usually a no-op now: the recovery path already released the manager's reference
// before the reload (the handle-drain that lets the PnP cycle proceed at all).
if reloaded {
super::manager::invalidate_cached_device(
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
@@ -1119,12 +1178,33 @@ fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedH
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
// between absent and not-ready is treated as the transition it is.
if probe.is_absent() {
if absent_since.is_none() && reload {
// First absent sighting on the recovery path: drop the manager's reference to the
// (dead) control device NOW, so the ABSENT_SETTLE below doubles as the drain window
// for every outstanding `Arc` clone — the handle then actually CLOSES before the
// reload runs. An open control handle is exactly what vetoes the PnP disable (and
// can wedge the pnputil restart) that the reload leans on; reset-pf-vdisplay.ps1
// stops the whole host service to get the same release (field 2026-08-08: every
// reload on a woken box came back REFUSED `Generic failure`). Gated on `reload`:
// the BRIEF_RETRY caller runs inside the manager's `device` mutex, where taking it
// again would deadlock — and that caller never reloads anyway.
super::manager::invalidate_cached_device(
"control interface absent — releasing the host's own device handle ahead of a \
possible adapter reload",
);
}
absent_since.get_or_insert_with(Instant::now);
} else {
absent_since = None;
}
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
// The not-ready path reaches here without the absent-sighting release above — drop the
// manager's reference now for the same reason (idempotent: a second call is a no-op).
super::manager::invalidate_cached_device(
"adapter reload imminent — releasing the host's own device handle (open handles \
veto the PnP cycle)",
);
match reload_vdisplay_adapter() {
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
// burning the arrival window on a box that simply does not have it installed.
@@ -1195,6 +1275,32 @@ mod tests {
}
}
/// A refusal must carry evidence, not just a verdict. The 2026-08-08 field log showed only
/// `REFUSED Generic failure` — the WMI catch-all — leaving handle-veto vs phantom vs
/// problem-state undecidable from the log. The enriched line's tokens (devnode counts, PnP
/// status, problem code, the pnputil restart exit code the old script discarded) must survive
/// decoding verbatim, and the phantom-only state must decode as a refusal too — a reload
/// cannot revive a devnode record whose device is gone.
#[test]
fn a_refusal_keeps_its_evidence() {
let why = match classify_reload_output(
"REFUSED devnodes=2 live=1 status=OK problem=0 restart_exit=3010 Generic failure",
) {
AdapterCycle::Refused(why) => why,
other => panic!("expected Refused, got {}", variant(&other)),
};
for token in ["devnodes=2", "live=1", "status=OK", "restart_exit=3010"] {
assert!(why.contains(token), "{token} must survive: {why:?}");
}
assert!(matches!(
classify_reload_output(
"REFUSED only phantom (not-present) adapter devnodes remain (2) - the device node \
itself is gone and no reload can revive it; reinstalling the host re-creates it"
),
AdapterCycle::Refused(why) if why.contains("phantom")
));
}
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
/// disable was refused and something still holds the device open).
@@ -1226,6 +1332,29 @@ mod tests {
));
}
/// The reap's outcome must decode losslessly — the field ratchet (0.23→0.25) was a reap whose
/// bare-named pnputil never launched under the LocalSystem PATH while the host stayed silent:
/// "no ghosts" and "removed nothing" were byte-identical. Found and removed now travel
/// separately so a leftover ghost is loud, and the old single-number output (or a powershell
/// that died before reporting) must not decode as anything.
#[test]
fn reap_output_decodes_found_and_removed() {
assert_eq!(parse_reap_output("3 3\r\n"), Some((3, 3)));
assert_eq!(
parse_reap_output("4 0"),
Some((4, 0)),
"pnputil unlaunchable"
);
assert_eq!(parse_reap_output("0 0"), Some((0, 0)), "clean box");
for dead in ["5", "", " ", "garbage", "OK"] {
assert_eq!(
parse_reap_output(dead),
None,
"{dead:?} is not a reap report"
);
}
}
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
+25 -18
View File
@@ -194,27 +194,29 @@ pub fn capture_virtual_output(
crate::inject::set_stream_target(Some(target.target_id));
let pref = vout.preferred_mode;
let keep = vout.keepalive;
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
// process-global — a dead one is retired, kept alive — so the raw value is stable for the
// process) and wrap `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls
// at ring attach. This is the ONE reach into `crate::vdisplay` the capturer would otherwise make;
// building it here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE and wrap
// `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls at ring attach.
// This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; building it
// here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
anyhow::anyhow!(
"pf-vdisplay control device not open (monitor not created via the manager?)"
)
})?;
// `HANDLE` is not `Send`; capture the raw value and rebuild it inside the closure (the control
// device is never closed for the process lifetime, so the value stays valid).
let control_raw = control.0 as isize;
// Each closure keeps its own `Arc<OwnedHandle>` clone (`Send + Sync`), so the handle is open
// for exactly as long as any delivery closure lives — and CLOSES once the manager retires it
// and the last session drops, which is what lets the wake-from-sleep recovery's PnP device
// cycle proceed (an open control handle vetoes it).
let control_frame = control.clone();
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is never
// closed for the process lifetime, so reconstructing the `HANDLE` and issuing the
// `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition).
// SAFETY: the captured `control_frame` Arc keeps the control handle open across this
// call — `send_frame_channel`'s precondition.
unsafe {
crate::vdisplay::driver::send_frame_channel(
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_frame),
),
req,
)
}
@@ -231,14 +233,17 @@ pub fn capture_virtual_output(
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
let control_cursor = control.clone();
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
std::sync::Arc::new(
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
// never closed for the process lifetime (`send_cursor_channel`'s precondition).
// SAFETY: the captured `control_cursor` Arc keeps the control handle open across
// this call (`send_cursor_channel`'s precondition).
unsafe {
crate::vdisplay::driver::send_cursor_channel(
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_cursor),
),
req,
)
}
@@ -261,11 +266,13 @@ pub fn capture_virtual_output(
target_id,
enable: enable as u32,
};
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
// never closed for the process lifetime (`send_cursor_forward`'s precondition).
// SAFETY: the captured `control` Arc keeps the control handle open across this call
// (`send_cursor_forward`'s precondition).
unsafe {
crate::vdisplay::driver::send_cursor_forward(
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control),
),
&req,
)?;
}
+2 -1
View File
@@ -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
+19
View File
@@ -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
+113
View File
@@ -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_<version>_<arch>.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 <packages@unom.io>"
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"
+1 -1
View File
@@ -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')
+18 -2
View File
@@ -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<N>` 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<N>` 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
+78
View File
@@ -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: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.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)"
@@ -0,0 +1,247 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
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 <algorithm>
+#include <vector>
+
extern int g_nPreferredOutputWidth;
extern int g_nPreferredOutputHeight;
+// `--custom-refresh-rates` (main.cpp): the rates this virtual display may switch between.
+extern std::vector<uint32_t> 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<const BackendMode> GetModes() const override
{
- return std::span<const BackendMode>{};
+ return m_Modes;
}
virtual bool SupportsVRR() const override
@@ -58,7 +70,7 @@ namespace gamescope
}
virtual std::span<const uint32_t> GetValidDynamicRefreshRates() const override
{
- return std::span<const uint32_t>{};
+ 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<const uint32_t> 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<BackendMode> m_Modes;
+ std::vector<uint32_t> 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<uint32_t> 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<uint32_t> parse_refresh_rate_list(const char *str)
+{
+ std::vector<uint32_t> 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<uint32_t> oRate = gamescope::Parse<uint32_t>( 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<int>(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 ||
@@ -0,0 +1,142 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
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<bool> 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<bool> 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;
@@ -1,6 +1,6 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
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)
@@ -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 <path-to-punktfunk-gamescope>
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.
+1 -1
View File
@@ -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 "") + ''
+4 -1
View File
@@ -147,7 +147,10 @@ if [ "$WITH_WEB" = 1 ]; then
distrobox enter "$BOX" -- bash -lc "
set -e
export PATH=\$HOME/.bun/bin:\$PATH
cd '$SRC/web' && bun install --frozen-lockfile && bun run build
# --ignore-scripts + explicit codegen: keep in step with scripts/steamdeck/update.sh, which
# explains why (web's `postinstall` writes the COMMITTED web/bun.nix; its `prepare`/codegen is
# required because src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored).
cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build
"
[ -f "$SRC/web/.output/server/index.mjs" ] || die "web build did not produce web/.output/server/index.mjs"
ok "web console built"
+30 -2
View File
@@ -20,7 +20,28 @@ TARGET_DIR="$SRC/target-steamos"
WEB=0; [ -f "$HOME/.config/systemd/user/punktfunk-web.service" ] && WEB=1
if [ "${1:-}" = "--pull" ]; then
if [ -d "$SRC/.git" ]; then log "git pull"; git -C "$SRC" pull --ff-only; ok "pulled"; else die "$SRC is not a git checkout — rsync new source then run without --pull"; fi
[ -d "$SRC/.git" ] || die "$SRC is not a git checkout — rsync new source then run without --pull"
# web/bun.nix and sdk/bun.nix are GENERATED (bun2nix, a pure function of the matching bun.lock —
# packaging/nix/README.md) yet COMMITTED, because the Nix build fetches node_modules only from
# them. Until the --ignore-scripts fix below, web's `bun install` here ran its `postinstall`
# (`bun2nix -o bun.nix`) and rewrote that tracked file on every single update. That is invisible
# while the committed file is in sync — but main carried a STALE web/bun.nix from 1db8f763 to
# b79d90b4, so any Deck updated in that window had the file rewritten to the *correct* content
# and has been sitting dirty ever since. The next `git pull --ff-only` that touches it then dies
# with "Your local changes to the following files would be overwritten by merge", and the update
# stops before a single service is restarted.
#
# Restore ONLY these two derived paths. Not a blanket `git reset --hard`: $SRC is the operator's
# own checkout (they may have patched a source file, or be carrying a cherry-pick), and silently
# deleting that to save an update is a far worse trade than one legible error. Discarding these
# two is provably lossless — regenerating them from the lockfiles is exactly what bun2nix does.
git -C "$SRC" checkout -- web/bun.nix sdk/bun.nix 2>/dev/null || true
log "git pull"
git -C "$SRC" pull --ff-only \
|| die "git pull --ff-only failed in $SRC. If it named locally-modified files, this checkout
has local changes: review them with 'git -C $SRC status', then commit or stash them (or discard
one with 'git -C $SRC checkout -- <file>') and re-run. Nothing was rebuilt or restarted."
ok "pulled"
fi
log "Rebuilding host (release)"
@@ -29,7 +50,14 @@ distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH
ok "host rebuilt"
if [ "$WEB" = 1 ]; then
log "Rebuilding web console"
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile && bun run build"
# --ignore-scripts, then `bun run codegen` explicitly: web has TWO install lifecycle scripts and
# we want exactly one of them. `prepare` (= codegen: orval + paraglide + the i18n check) is
# REQUIRED — src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored, and `prebuild`
# only re-runs orval, so dropping codegen leaves the build without its i18n messages. But
# `postinstall` (`bun2nix -o bun.nix`) writes a COMMITTED file, and an updater must never dirty
# the tree it just pulled into — that is what broke `--pull` above. The SDK install below has
# always passed --ignore-scripts, which is why only web/bun.nix ever went dirty.
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build"
ok "web rebuilt"
fi