Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f80636f901 | ||
|
|
0f79587dd6 | ||
|
|
651a7a82a1 | ||
|
|
4d383811c0 | ||
|
|
42ee6c5628 | ||
|
|
08eaf337e8 | ||
|
|
39869031be | ||
|
|
55f361cb92 | ||
|
|
2079411f4f | ||
|
|
4d1a1348c0 | ||
|
|
e5180a5b7d | ||
|
|
4070d043d6 | ||
|
|
ebf61cb448 | ||
|
|
4a92c64144 | ||
|
|
2426056465 | ||
|
|
d3aaa16a7d | ||
|
|
2dd65bdd41 | ||
|
|
5cbaca7789 | ||
|
|
9c854893bc | ||
|
|
6b7997cace | ||
|
|
78ba2342b5 | ||
|
|
7d37fe450d | ||
|
|
077db416ec | ||
|
|
fd98406868 | ||
|
|
5c70a90358 | ||
|
|
29248dcab9 | ||
|
|
95962f55d0 | ||
|
|
9e598f8595 | ||
|
|
bd86598d97 | ||
|
|
c3ecc29117 | ||
|
|
6d550530fe | ||
|
|
e22082ac2a | ||
|
|
4f5ca5f9bc | ||
|
|
07f6d6f324 | ||
|
|
54666e66da | ||
|
|
5872dfc649 | ||
|
|
bed58b75b6 | ||
|
|
ce31a9ddfd | ||
|
|
8fe834c89b | ||
|
|
744bcb468b | ||
|
|
20f4d23f2d | ||
|
|
49f5c815ea | ||
|
|
9e7713eecf | ||
|
|
9232631299 | ||
|
|
0cd946acb5 | ||
|
|
62a6fa9fac | ||
|
|
d402e9b996 | ||
|
|
f23e0df64c | ||
|
|
12f39e1967 | ||
|
|
8387e48ac6 | ||
|
|
fb60bf653e | ||
|
|
2a1c968a0e | ||
|
|
b815e00a87 | ||
|
|
eb8c943572 | ||
|
|
102f550bba | ||
|
|
818531a26e | ||
|
|
608baf63be | ||
|
|
767e67caf4 | ||
|
|
8f9c72877e | ||
|
|
8f32976349 | ||
|
|
2bf571a5ad | ||
|
|
deef5e4382 | ||
|
|
9c24569db6 | ||
|
|
32cc8dd529 | ||
|
|
fba22c6c64 | ||
|
|
2aa763ce70 | ||
|
|
f242b2d2fc | ||
|
|
975fef2048 |
@@ -320,6 +320,74 @@ 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
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
apt-get install -y --no-install-recommends libglm-dev cmake libxcursor-dev || 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
|
||||
# Best-effort on canary, HARD on a stable tag — same reasoning as rpm.yml. A warning
|
||||
# here is what let v0.26.0 promise an apt-installable gamescope that was never built.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::punktfunk-gamescope failed to build on noble — a stable tag must not ship without it (the release notes and docs-site say it is installable)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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
|
||||
# Authoritative, and covers the cache path the build step skips — same as rpm.yml.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::no usable punktfunk-gamescope — a stable tag must not ship without its .deb"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -206,13 +206,79 @@ jobs:
|
||||
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
||||
dnf builddep -y gamescope || true
|
||||
dnf -y install xorg-x11-server-Xwayland-devel || true
|
||||
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
|
||||
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
|
||||
# without the static library meson's very FIRST sanity check dies with
|
||||
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
|
||||
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
|
||||
# the job stayed green, and the release shipped with no gamescope RPM while the notes
|
||||
# said it had one. A rename here must be LOUD, hence no `|| true`.
|
||||
dnf -y install libstdc++-static
|
||||
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
|
||||
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
|
||||
# does without, and a name that moves between Fedora releases should not fail the job.
|
||||
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || 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
|
||||
# Best-effort on canary, HARD on a stable tag. A `::warning::` here is what let
|
||||
# v0.26.0 publish release notes promising a Fedora gamescope package that did not
|
||||
# exist: the step "succeeded", the job was green, and the only evidence was a warning
|
||||
# nobody reads. A release must not be able to make a claim its own CI silently
|
||||
# dropped, so on `refs/tags/v*` this is an error.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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
|
||||
# The authoritative "does this package exist" decision, and the one that also covers
|
||||
# the cache path — the build step above is skipped entirely on a cache hit, so a stale
|
||||
# or empty cache would otherwise reach here and skip in silence on a release.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::no usable punktfunk-gamescope for f${{ matrix.fedver }} — a stable tag must not ship without its RPM"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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.
|
||||
|
||||
+434
@@ -12,6 +12,426 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.26.0
|
||||
|
||||
52 commits since v0.25.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.25.0 | v0.26.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 17 | **17** | unchanged — no symbol added, removed or changed |
|
||||
| Workspace crate dirs | 26 | **26** | unchanged (40 workspace members) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.24.0 | **0.25.0** | tracks API edits, lags one release by convention |
|
||||
| gamescope patch level (`+pfhdrN`) | 2 | **4** | 3 patches → 6; `pkgrel` 1 → 2 |
|
||||
| `@punktfunk/host` (SDK) | 0.1.2 | **0.1.4** | |
|
||||
| `@punktfunk/plugin-kit` | 0.3.2 | **0.4.0** | the `plugin` launch kind |
|
||||
|
||||
`crates/pf-driver-proto` is byte-for-byte identical to v0.25.0 and to v0.24.0 — if you ship the
|
||||
virtual-display driver or the gamepad channel, the last two releases have not touched you.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** This is a fixes release. Every embedder, packager and plugin that works against v0.25.0
|
||||
works against v0.26.0 unchanged. Two behaviour changes are worth knowing about anyway, because both
|
||||
make a client advertise *less* than it used to — see **Capability advertisement** below.
|
||||
|
||||
### Capability advertisement
|
||||
|
||||
- **`VIDEO_CAP_444` is now probed, not asserted.** It rode the "Full chroma" setting alone. That was
|
||||
safe while a software HEVC decoder sat underneath it; M8 removed one (there is no permissively
|
||||
licensed HEVC CPU decoder, so `software_decodable_codecs()` is `H264|AV1`). The host grants 4:4:4
|
||||
on HEVC **only** and answers the resolved chroma in the `Welcome` *before* the client builds a
|
||||
decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the whole
|
||||
codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU
|
||||
rung, and the session reconnects on H.264. No AMD silicon has HEVC 4:4:4 decode, so every Steam
|
||||
Deck with that switch on lost HEVC. Per-profile and default-off, which is why it read as
|
||||
intermittent.
|
||||
|
||||
Now gated on `hevc_444_hardware_decodable`, which asks the driver through the same code the rung
|
||||
uses at construction (`VkH265Decoder::probe_stream_support`). **Both depths are required**, not
|
||||
either: with HDR the host may resolve 4:4:4 10-bit, and a device offering `YUV444_8` but not
|
||||
`YUV444_10` lands in the same hole. Answering from the Vulkan rung alone is exact rather than
|
||||
approximate — it is the only rung in this build that implements 4:4:4 at all
|
||||
(`pf_vaadec::profile_for` errors on `chroma_format_idc 3`, pf-dxvadec refuses anything but 4:2:0,
|
||||
the CPU rung is 8-bit 4:2:0).
|
||||
|
||||
⚠ Deliberately **not** extended to `VIDEO_CAP_10BIT`/HDR: all three rungs implement 10-bit 4:2:0,
|
||||
so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it
|
||||
perfectly — a regression against a case never observed.
|
||||
|
||||
The bit arithmetic moved into `video::video_caps_for` so the part that was wrong is testable
|
||||
without a GPU, a host or a `Hello`; the test is verified non-vacuous against the planted defect.
|
||||
|
||||
### Host and client environment variables
|
||||
|
||||
Four new, one clarified. Verified new by `git grep` at the v0.25.0 tag, not assumed —
|
||||
`PUNKTFUNK_JUMBO`, `PUNKTFUNK_WIRE_MTU`, `PUNKTFUNK_STREAMED_AU`, `PUNKTFUNK_LIBRARY_ART_ROOTS`,
|
||||
`PUNKTFUNK_RECOVER_SESSION_CMD`, `PUNKTFUNK_GAMESCOPE_SDR_NITS`, `PUNKTFUNK_MAX_FPS` and
|
||||
`PUNKTFUNK_ON_CONNECT_CMD` all already existed.
|
||||
|
||||
- **`PUNKTFUNK_OVERLAY_MASK`** *(new, client)* — controls the Steam-overlay input mask below.
|
||||
- **`PUNKTFUNK_PYROWAVE_CHUNK_KIB`** *(new)* and **`PUNKTFUNK_PYROWAVE_STREAMED_AU`** *(new)* —
|
||||
PyroWave AU chunking and the streamed-AU path.
|
||||
- **`PYROWAVE_QUEUE_PRIORITY`** *(existed, but was inert on Linux — see below)* — grammar: unset →
|
||||
realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
|
||||
the ladder rather than to off. ⚠ **One env var must not mean two things on two platforms**, so
|
||||
the Rust grammar is unit-tested against the C patch's, including where both are deliberately
|
||||
un-clever (neither trims).
|
||||
- **`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`** *(new)* — widens the set a gamescope 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.
|
||||
Requires gamescope patch level 3+.
|
||||
- **`PUNKTFUNK_COMPOSITOR`** *(behaviour clarified, not changed)* — documented as "which backend to
|
||||
drive", it also silently discarded `game_session=dedicated`: `resolve_compositor` gated the
|
||||
dedicated route on `!overridden` and logged nothing either way. The pin still wins — it is the
|
||||
operator's explicit knob — but it now says so and names itself. Two further holes closed with it:
|
||||
the pin put its backend into `available()` unconditionally *and* skipped `apply_session_env`'s
|
||||
`XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could never return `None` — the one call site of
|
||||
`try_recover_session()`, which left `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable behind that arm.
|
||||
Liveness is now read on both paths. `needs_live_session()` exempts gamescope, which stands up its
|
||||
own session, so pinning it on a headless box stays supported.
|
||||
|
||||
### Client settings keys
|
||||
|
||||
All additive; an older client ignores what it does not know, and a newer value can never trap an
|
||||
older client.
|
||||
|
||||
- **`gamepad_ui_mode`** — `"connected"` (default, and exactly what the previous lone Bool meant) or
|
||||
`"always"`. Splits *whether* the controller UI is offered from *when* it appears.
|
||||
`GamepadUIEnvironment.isActive` takes the mode with **no default argument** on purpose: a call
|
||||
site that forgot it would silently strand everyone who chose Always. An unrecognized value waits
|
||||
for a controller.
|
||||
- **`ui_palette`** gains `oled` at **index 1**, directly after the brand default — keeping
|
||||
`PALETTES[0]` the unknown-id fallback and the dark-to-pale cycling order intact. Hand-mirrored in
|
||||
three languages (`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`); each
|
||||
port carries an `oled_is_actually_black` test that measures the claim (mean cell luminance 0.019
|
||||
against Violet's 0.254) rather than restating the table.
|
||||
- **`library-hidden.json`** — per-title hide list, mirroring how `library-scanners.json` holds
|
||||
disabled sources. Deliberately **not** stored on the entry: a scanner's and a plugin's titles are
|
||||
rebuilt from scratch on every scan and reconcile, so a flag written onto one would be erased
|
||||
minutes later. Applied in `all_games`, the single funnel every play surface already goes through
|
||||
(client grid, native clients, the GameStream app list, launch resolution).
|
||||
|
||||
### gamescope patches
|
||||
|
||||
Three → six, 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.** `CHeadlessConnector`
|
||||
returned empty spans from `GetModes()` and `GetValidDynamicRefreshRates()` and reported
|
||||
`GAMESCOPE_SCREEN_TYPE_INTERNAL`, so `update_mode_atoms` **deleted** the mode-list atom and
|
||||
wlserver fell through to a one-entry refresh list built from `g_nOutputRefresh` — which, with
|
||||
`--nested-refresh` absent, is `Init()`'s 60 Hz default. That is why a 1920x1080@120 client saw
|
||||
"gamescope only shows 60hz" and Overwatch capped itself to 60 while the stream ran at 120. Now
|
||||
populates both from the resolved mode, reports `EXTERNAL`, and adds `--custom-refresh-rates`.
|
||||
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. `paint_pipewire` has never referenced it on any version. Behind
|
||||
`--pipewire-composite-external-overlay`, off by default.
|
||||
- **0006 — never destroy the Vulkan device or output.** `g_device` (`CVulkanDevice`) and `g_output`
|
||||
(`VulkanOutput_t`) were plain globals, so glibc ran their destructors from `__run_exit_handlers`
|
||||
once `main()` returned — calling back into an ICD that had already been torn down and unloaded.
|
||||
Faulting address equalling the instruction pointer is the signature. Reproducible with
|
||||
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true` (exit 139, every
|
||||
time). Both globals get storage constructed exactly as before but never destroyed; pinning only
|
||||
the device relocated the fault into `~VulkanOutput_t`, hence a shared `CNoDestroy<T>`.
|
||||
|
||||
⚠ **`+pfhdrN` deliberately does not move for 0006.** The marker is a capability tier the host
|
||||
probes via `gamescope_patch_level()` *before* it spawns; this patch adds no capability, so bumping
|
||||
it would advertise a tier that does not exist. Ships as a `pkgrel` bump instead.
|
||||
|
||||
⚠ gamescope CI legs are best-effort — a broken patch is a **missing package**, not a red run.
|
||||
|
||||
### Virtual-display handle ownership (Windows)
|
||||
|
||||
The control-device sharing contract was "bare `HANDLE` copies, never closed for the process
|
||||
lifetime": retired handles were kept alive because pinger/linger threads and capture closures held
|
||||
raw copies whose soundness depended on no-close. An open control handle is exactly what vetoes the
|
||||
PnP disable — and can wedge the `pnputil` restart — that wake-from-sleep recovery leans on, so every
|
||||
post-wake adapter reload came back REFUSED. `reset-pf-vdisplay.ps1` stops the whole host service
|
||||
precisely to get those handles closed; the in-process recovery could not.
|
||||
|
||||
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 (ending the
|
||||
`isize` smuggling — `Arc<OwnedHandle>` is `Send + Sync`), and retiring drops only the manager's
|
||||
reference. `DeviceSlot::retired` is gone.
|
||||
|
||||
⚠ **Nothing may store a bare control `HANDLE` again.** The whole fix is that the handle closes when
|
||||
the last in-flight user drains.
|
||||
|
||||
### Presenter — points are not pixels
|
||||
|
||||
`SDL_GetDesktopDisplayMode` reports a mode in **screen coordinates** and hands the pixels-per-point
|
||||
ratio back separately as `pixel_density`; `m.w`/`m.h` were read raw. KDE advertises a 2560x1600 panel
|
||||
at 150 % as 1707x1067 points with a density of ~1.4997, `render_scale::apply` even-floors both odd
|
||||
axes, and 1706x1066 went on the wire. Multiplying by the density recovers 2560x1600 to the pixel.
|
||||
|
||||
⚠ Inert on X11 and Windows: SDL never sets a density there and `SDL_video.c` normalizes the unset
|
||||
0.0 to 1.0. **This bug needed a compositor doing fractional scaling.**
|
||||
|
||||
Second, independent defect: the SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland
|
||||
surface stayed at buffer scale 1 and the swapchain was built at 1707x1067 for KWin to upscale. That
|
||||
one also silently shrank "Match window", which asks the host for `size_in_pixels()`.
|
||||
|
||||
### Apple audio session
|
||||
|
||||
`micEnabled` and `echoCancel` both default to `true`, so the **default** iOS session is
|
||||
`.playAndRecord` — and that branch set `.defaultToSpeaker`. That option is an output **override**,
|
||||
not a preference, and it outranks an A2DP route. ⚠ **Wired headphones beat it, Bluetooth does not**,
|
||||
so testing with a cable returns the wrong answer — which is what the comment sitting on it asserted.
|
||||
|
||||
Now solved against the route actually given: after activation, if the current output is
|
||||
`.builtInReceiver`, override to speaker; anything external (Bluetooth, wired, CarPlay, AirPlay) is
|
||||
left strictly alone. The override is a property of the current route — iOS drops it on every route
|
||||
change, which is what lets a newly-connected headset win — so it is re-applied per route via an
|
||||
observer, registered only for `.playAndRecord`, removed in `stop()` before deactivate, `deinit` as
|
||||
backstop. Without it, dropping Bluetooth mid-stream lands on the earpiece.
|
||||
|
||||
⚠ Deliberately **not** adding `.allowBluetooth`: it would make a headset's mic usable but drag the
|
||||
whole route onto HFP/SCO and collapse game audio to narrowband.
|
||||
|
||||
### Audio jitter policy
|
||||
|
||||
`JitterPolicy` (`punktfunk-core/src/audio.rs`, used by Linux/Windows/Android) and its mirror in
|
||||
Swift `AudioRing`. The policy learned exclusively from audible failures on both sides: growth needed
|
||||
**three** audible underruns; the A/V sync loop re-tested a shallower ring every five quiet seconds
|
||||
and paid an audible starvation event every time it was wrong, forever; and a grown target was never
|
||||
re-banked (growth raises a threshold — only a re-prime deepens the ring), so a bunching link rode
|
||||
the knife edge with the "grown" target sitting inert.
|
||||
|
||||
Three mechanisms: **near-miss** (a read served with less than one protocol frame left over is the
|
||||
same evidence as an underrun, heard by no one — grows one step per window, *before* the click);
|
||||
**shrink probes** (every shrink armed for 5 s, undone on the spot if answered by an underrun or
|
||||
near-miss, with a doubling backoff 60 s → 8 min on a failed sync-driven shrink; a surviving probe
|
||||
resets it); **hollow re-prime** (an underrun while the depth *average* runs more than a step below
|
||||
target re-primes immediately — the average, not the instant, separates a hollow ring from one late
|
||||
packet, and it is seeded on prime so a fresh ring is never spuriously hollow).
|
||||
|
||||
Measured on a ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm
|
||||
skew): **~2000 audible events → 9.**
|
||||
|
||||
### Plugins, SDK and the runner
|
||||
|
||||
- **`category` never shipped.** The console correctly keeps `category: "library"` plugins out of the
|
||||
nav; the host reported no category for them at all. `defineLibraryPlugin` sets it and
|
||||
`sdk/src/ui.ts` forwards it — what shipped did not: `@punktfunk/host` was bumped to 0.1.2 on
|
||||
2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's 0.1.2 is the
|
||||
pre-category build. ⚠ **Inert until published.** `serveUi` now reads its own directory entry back
|
||||
and warns once when a requested category did not land.
|
||||
- **Local art sync failed on a `file://` disagreement.** `local_art_bytes` decodes a `file://` value
|
||||
before testing containment; `validate_art_paths` handed the raw value to `Path::new`. Same defect
|
||||
produced both the unreachable settings and `sync (startup) failed: HostRequestError`.
|
||||
- **The runner now carries SDK updates.** The copy each installed plugin runs was pinned at install
|
||||
time, so an SDK fix could never reach it.
|
||||
- **`bun publish` runs `prepare`, and `prepare` needs bun2nix** — the SDK could not be published at
|
||||
all. Also fixed: a corrupt committed `bun.lock` in plugin-kit.
|
||||
- **Decky client update.** `flatpak remote-info punktfunk-origin io.unom.Punktfunk` names no branch;
|
||||
the remote publishes `stable` **and** `canary`, so the ref is ambiguous and flatpak refuses it —
|
||||
⚠ one branch being *installed* does not disambiguate, the ambiguity is on the remote. The call
|
||||
failed on every box, every time, and returned `available=False`, which the panel rendered as good
|
||||
news. Every query now names the ref in full via `_flatpak_ref()` (no subprocess), carrying the
|
||||
**scope** too, so a system-wide install is no longer invisible to a check that hardcoded `--user`.
|
||||
A check that cannot run now reports `client_error`.
|
||||
|
||||
### Packaging
|
||||
|
||||
- **The `punktfunk` group is created everywhere the udev rule needs it.** `60-punktfunk.rules`
|
||||
chgrp's the usbip vhci attach/detach nodes to a dedicated group (security review 2026-08-05 M-4:
|
||||
writing `attach` materialises an arbitrary emulated USB device, so it must not ride on `input`).
|
||||
**Four of six install paths shipped that rule in 0.25.0 without creating the group** — chgrp
|
||||
failed, nodes stayed `root:root 0644`, the virtual Deck pad silently never attached, and
|
||||
`usermod -aG punktfunk` failed outright. Fixed in arch `post_upgrade()` (only `post_install` was
|
||||
correct, so every box that reached 0.25.0 by `pacman -Syu` missed it), nix (`users.groups.punktfunk`
|
||||
did not exist), the bazzite sysext (a group is host state and cannot ride an image), and the Steam
|
||||
Deck scripts. deb and rpm were correct throughout.
|
||||
- **`punktfunk-gamescope` now builds for RPM and apt**, not Arch only.
|
||||
- **Arch release-rebuild prune** called a helper that cannot exist in a release rebuild. Together
|
||||
with the FFmpeg 9 repackage this closes the 0.25.0-1 → 0.25.0-2 episode in the pipeline rather
|
||||
than by hand.
|
||||
- **Steam Deck `update.sh` / `install.sh`.** The web step ran `bun install --frozen-lockfile` with
|
||||
no `--ignore-scripts`, so web's `postinstall` (`bun2nix -o bun.nix`) rewrote a **tracked** file on
|
||||
every update; the SDK step below it had always passed `--ignore-scripts`, and that asymmetry is
|
||||
the whole bug. Now `--ignore-scripts` plus an explicit `bun run codegen` — provably equivalent,
|
||||
since web's `prepare` is literally `"bun run codegen"` and `src/api/gen`, `src/paraglide` and
|
||||
`src/routeTree.gen.ts` are gitignored. `--pull` restores `web/bun.nix` and `sdk/bun.nix` before
|
||||
pulling, which is lossless by construction. ⚠ Deliberately **not** `git reset --hard`: `$SRC`
|
||||
defaults to the operator's own checkout. Also: `web.env` secret hygiene — `chmod 600` sat inside
|
||||
the create-only branch, so an install set up once and only updated since kept it world-readable.
|
||||
⚠ `packaging/debian/build-web-deb.sh`, `packaging/arch/PKGBUILD` and `packaging/rpm/punktfunk.spec`
|
||||
still lack `--ignore-scripts` for web — harmless (throwaway build trees), left as follow-up.
|
||||
|
||||
### Triage tooling
|
||||
|
||||
**`--probe-decode` described a different device from the one that streams.** The RADV
|
||||
video-decode opt-in sat *after* the `--list-adapters` / `--probe-decode` / `--list-audio` / `--pair`
|
||||
early exits, so the triage tool never had it. Measured on a Deck, same binary back to back: bare
|
||||
`--probe-decode` printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue
|
||||
family advertises VIDEO_DECODE"; with `RADV_PERFTEST=video_decode` in the environment, "YES" and
|
||||
"H.264, H.265, AV1, VP9". ⚠ **Any Deck triage that consulted it reached the opposite of the truth.**
|
||||
Hoisted to the top of `run`, ahead of every early exit.
|
||||
|
||||
### PyroWave on Linux — Wave 2
|
||||
|
||||
The program's own measurement, from patch 0005's header: `encode_gpu_synchronous` goes from ~2 ms
|
||||
to **15–18 ms at 95 % game load**, with the stream frame rate collapsing. PyroWave encodes on the
|
||||
same shader cores a game saturates; NVENC is immune because it has its own ASIC.
|
||||
|
||||
- **PW1 — the GPU-priority lever had never fired on Linux.** The vendored patch requests an elevated
|
||||
global-priority queue, gated `if (!inherit_info)` — and **only Windows leaves `inherit_info` null**
|
||||
(`pyrowave_create_device_by_compat`, where Granite builds the device itself). Linux passes its own
|
||||
create-infos, Granite's `get_existing_create_info()` hands them back, `create_device` takes the
|
||||
inherit branch, and the whole block is skipped. Now wired natively in `open_inner`'s `DeviceHold`,
|
||||
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
|
||||
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
|
||||
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
|
||||
⭐ **Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
|
||||
nothing.
|
||||
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
|
||||
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
|
||||
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
|
||||
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
|
||||
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
|
||||
that readlink to any reader whose effective set is not a superset of the target's **permitted**
|
||||
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
|
||||
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
|
||||
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
|
||||
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
|
||||
and is opt-in for gamescope-only hosts, which have no such identity check.
|
||||
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
|
||||
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
|
||||
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
|
||||
old layout, plus three `fill_buffer` clears). Two encodes submitted to one queue have **no**
|
||||
execution dependency in Vulkan — submission order orders the start, not the completion — so N+1's
|
||||
DWT would overwrite N's wavelet bands while N's block packing still reads them. Content-dependent
|
||||
and silent. Overlap therefore means two handles alternated, one per slot. ⚠⚠ **The landmine:**
|
||||
`sequence_count` also lives on `Impl`, and it is the **3-bit** counter stamped into every block
|
||||
header. Two handles each counting 1,2,3… put 1,1,2,2,3,3… on the wire, and the decoder restarts a
|
||||
frame only when the value *changes* — so a repeat reads as more blocks of the same frame. Depth is
|
||||
**still 1**; the handles alternate with one in flight.
|
||||
- **PW3 — the fence wait moved out of submit.** PyroWave was the one backend waiting its fence inside
|
||||
`submit`.
|
||||
- **PW7a — the jumbo leg was dead code.** quinn caps a peer's MTU-discovery search at
|
||||
`min(MtuDiscoveryConfig::upper_bound, the other side's advertised max_udp_payload_size)`, and
|
||||
`EndpointConfig::max_udp_payload_size` **defaults to 1472**. Nothing in the repo had ever touched
|
||||
`EndpointConfig`, so raising the host's probe ceiling could never make discovery settle above 1472
|
||||
— and the shipped mid-session grow's `settled >= sealed_datagram_bytes(target)` gate was
|
||||
unreachable on **every path that has ever existed**. Two smaller contributors fixed with it: the
|
||||
watcher stopped sampling the moment `settled >= 1472`, discarding the very climb the proof needs;
|
||||
and a session sealed above the 1500-byte default was never checked against the path at all.
|
||||
|
||||
The advertisement is raised on the **client** endpoint under the same `jumbo_wire_mtu()` opt-in,
|
||||
because it is not free: quinn sizes its endpoint receive buffer
|
||||
`max_udp_payload_size × max_receive_segments × BATCH_SIZE` — on a GRO-capable Linux/Android client
|
||||
that is ~2.9 MiB at the default and **~18 MiB at jumbo** (47 KiB → 288 KiB on Apple/Windows).
|
||||
PyroWave is the codec that most wants this: it can never be re-keyed mid-stream (its client parses
|
||||
chunk-aligned AUs in windows of the `Welcome` value, read once over the C ABI), so it should
|
||||
*start* at the big shard. At an 8908-byte shard that is ~6× fewer datagrams per frame — **~49k → ~8k
|
||||
pps at 550 Mb/s**.
|
||||
|
||||
### Zero-copy capture
|
||||
|
||||
- **The dmabuf latch conflated two causes with different lifetimes.** One `AtomicBool` served both
|
||||
"the encoder repeatedly failed to import what this compositor allocates" (unrecoverable, a driver
|
||||
fact) and "the dmabuf-only capture offer never negotiated" (which can just mean the compositor was
|
||||
mid-restart). Sharing it made the second as permanent as the first: **one timeout, and every later
|
||||
session on that host captured CPU frames until the process restarted** — including sessions against
|
||||
a different compositor and a different node that had never failed at anything, with nothing said.
|
||||
Now a `RawDmabufLatch` owning both: import failures stay sticky (unchanged 3-consecutive threshold);
|
||||
negotiation timeouts get a retry budget of **2** — deliberately small, since each failure costs a
|
||||
~10 s stall the user pays in dead air; a capture that negotiates credits the budget back; and both
|
||||
are keyed to a capture identity (node id + portal bit).
|
||||
- **The zero-copy path never asked for buffer headroom.** `build_dmabuf_buffers` set
|
||||
`SPA_PARAM_BUFFERS_dataType` and stopped — no `SPA_PARAM_BUFFERS_buffers` at all, so the pool depth
|
||||
every zero-copy safety argument rests on was entirely the producer's choice and we never expressed
|
||||
a preference. Now asks for 8 (min 2, max 16) as a **Choice Range, deliberately not a fixed count**:
|
||||
SPA intersects consumer and producer params, so a fixed 8 against a producer that can only afford 4
|
||||
empties the intersection and the link stalls in "negotiating" with no error anywhere — ⚠ the exact
|
||||
trap that once cost this codebase the entire Linux cursor channel, when a 256² cursor-meta max
|
||||
failed to intersect Mutter's fixed 384². 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz;
|
||||
16 is a ceiling, not a request (a 4K 4:4:4 buffer is ~25 MB).
|
||||
- **A PyroWave session could drop to CPU capture and log nothing.** The CPU-fallback warning was gated
|
||||
on `backend_is_vaapi`, which reads the **host-global** encoder pref — but a PyroWave session is
|
||||
negotiated **per session**, so on an NVIDIA/auto host that gate is false and the session fell out of
|
||||
every arm of the negotiation log chain while paying a full-resolution CPU pixel touch every frame.
|
||||
A degraded host and a healthy one produced identical logs. Now asks the per-session question
|
||||
(`consumer_kind`), widened to every GPU consumer and excluding only the software encoder, whose
|
||||
native input *is* CPU frames. ⚠ `pyrowave_session` must outrank `backend_is_vaapi`, because a
|
||||
PyroWave pref flips `backend_is_vaapi` on too.
|
||||
|
||||
### Steam-overlay input masking (Steam Deck)
|
||||
|
||||
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the **same physical controller** the
|
||||
client forwards, so opening either moved the game on the host as well — a second, invisible player.
|
||||
Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's
|
||||
virtual pad and we deliberately forward the **real** one (the virtual pad has no gyro, trackpads or
|
||||
paddles).
|
||||
|
||||
⚠ **SDL's own gate cannot fire on a Deck.** SDL drops presses while a process has windows but no
|
||||
keyboard focus, and it is on by default — but gamescope resolves focus per Xwayland ctx and the client
|
||||
sits alone in its own, so the Steam overlay (which lives in the root ctx) never takes our X focus and
|
||||
no `FocusOut` is ever generated. Measured on glass: with the QAM open, X input focus inside the
|
||||
client's ctx stayed on its window for the whole 4 s while `GAMESCOPE_FOCUSED_APP` flipped to 769
|
||||
(Steam) and `GAMESCOPE_FOCUSED_APP_GFX` stayed on the app. **That pair of atoms is the signal.**
|
||||
|
||||
⚠ `overlay_focus` watches them on the gamescope **root** ctx, which is *not* our own `$DISPLAY` under
|
||||
`--xwayland-count 2` — hence the socket-directory walk and the flatpak filesystem line.
|
||||
|
||||
⚠⚠ Masking is deliberately **not** `set_forwarding`: that closes the slot and sends `GamepadRemove`,
|
||||
so the game would see a controller **unplug** every time somebody opened the QAM. Every slot stays
|
||||
open and only transitions stop, after flushing what the host believes is held (so a stick deflected at
|
||||
overlay-open stops steering instead of freezing at its last value). On the way back, held buttons are
|
||||
**adopted rather than replayed** — the A that picked a QAM row must not fire in the game as it closes
|
||||
— while axes *are* re-sent, since a stick has no press to ghost and SDL only speaks on change.
|
||||
|
||||
### The `plugin` launch kind
|
||||
|
||||
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses on the
|
||||
**first** offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`, stopped
|
||||
putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind
|
||||
the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured,
|
||||
with the core and flags they chose", so that trick does not generalise.
|
||||
|
||||
The entry now carries an **opaque key and nothing executable**, and the host asks the owning plugin
|
||||
what to run at launch time, over the loopback UI port and per-boot secret it already registered.
|
||||
⭐ **A stolen plugin token stops being command execution:** planting an entry is not enough, because
|
||||
the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to
|
||||
a client, and an emulator that moved is picked up on the next launch rather than leaving a dead tile
|
||||
(same reasoning as `xbox` resolving its AUMID at launch time).
|
||||
|
||||
⚠ **The host still spawns it**, because only the host can put the process where the stream can see it:
|
||||
on Linux that is either gamescope's own argv or a spawn carrying the session's compositor env, and the
|
||||
returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the
|
||||
emulator itself would land it outside both.
|
||||
|
||||
### Verification status
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| gamescope 0006 | 6/6 exit 0 on a release build at the real spawn shape (`2752x2064@120 --steam --xwayland-count 1`); distro control SIGSEGVs |
|
||||
| Decky client update | on the Deck against the real install — pre-fix `available=False remote=''`, post-fix `available=True remote=ca010668` |
|
||||
| `--probe-decode` | on a Deck, same binary back to back, with and without the RADV opt-in |
|
||||
| Apple audio | builds on arm64-apple-ios17.0 (the triple that compiles the `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them), arm64-apple-tvos17.0, macOS; 257 Swift tests |
|
||||
| Audio jitter | 10-minute Wi-Fi power-save simulation, ~2000 → 9 audible events |
|
||||
| 4:4:4 gate | test verified non-vacuous against the planted original defect |
|
||||
| Steam Deck scripts | `bash -n` + shellcheck 0.11.0 clean at `-S warning`; exec bits preserved |
|
||||
| Steam-overlay masking | on glass on a Deck — atom flip and X-focus non-flip both measured over a 4 s QAM open |
|
||||
| PyroWave depth 2 | exercised on real hardware **without shipping depth 2** (dedicated test, shipped depth stays 1) |
|
||||
| PW6 streamed AU | the trap is real, and at 2 % loss it costs exactly nothing |
|
||||
|
||||
⏳ **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
|
||||
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
|
||||
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
|
||||
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
|
||||
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0
|
||||
|
||||
407 commits since v0.24.0.
|
||||
@@ -79,6 +499,20 @@ capability rode on `input`, which every gamepad guide tells users to join — bu
|
||||
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
||||
attaching. Ordinary virtual gamepads are unaffected.
|
||||
|
||||
> **Known issue in 0.25.0, fixed after it.** Four of the six install paths shipped
|
||||
> `60-punktfunk.rules` — whose `RUN+=` does `chgrp punktfunk` on the vhci `attach`/`detach` nodes —
|
||||
> without ever creating the group, so the `chgrp` failed, the nodes stayed root-only, and the pad
|
||||
> silently never attached. The `usermod` above also fails outright on those boxes with *group
|
||||
> 'punktfunk' does not exist*. Affected: **Arch/CachyOS upgraded** rather than freshly installed
|
||||
> (`post_upgrade` called only `_ensure_update_group`), the **NixOS module** (no
|
||||
> `users.groups.punktfunk`), the **Bazzite sysext** (a group is host state and cannot ride an
|
||||
> image), and **Steam Deck source installs** (`scripts/steamdeck/install.sh`/`update.sh` handled
|
||||
> only `input`). The deb and rpm scriptlets were correct throughout — they run one `%post`/`postinst`
|
||||
> on install and upgrade alike. All four now create the group, and the two that know which user
|
||||
> runs the host (the Deck scripts and the NixOS module's `host.users`) add that user to it as well.
|
||||
> Workaround on an unpatched box:
|
||||
> `sudo groupadd --system punktfunk`, then the `usermod`, then re-login.
|
||||
|
||||
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
||||
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
||||
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
||||
|
||||
Generated
+36
-35
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2358,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3047,11 +3047,12 @@ dependencies = [
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3069,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3090,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3100,7 +3101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3124,7 +3125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3136,7 +3137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3150,11 +3151,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3183,14 +3184,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3205,7 +3206,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3213,7 +3214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3225,7 +3226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3234,7 +3235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3267,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3278,7 +3279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3290,7 +3291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3513,7 +3514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3524,7 +3525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3542,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3559,7 +3560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3574,7 +3575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3593,7 +3594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3625,7 +3626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3710,7 +3711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3724,7 +3725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3747,7 +3748,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -906,6 +906,7 @@ struct ContentView: View {
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
@@ -79,6 +79,13 @@ public final class SessionAudio {
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
#if os(iOS)
|
||||
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
|
||||
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
|
||||
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
|
||||
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
#endif
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
@@ -89,6 +96,11 @@ public final class SessionAudio {
|
||||
/// Engine teardown still belongs to stop().
|
||||
deinit {
|
||||
flag.stop()
|
||||
#if os(iOS)
|
||||
// The observer only holds self weakly, so we can be deinited with it still registered;
|
||||
// drop the token here too rather than leaking it when an owner skips stop().
|
||||
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
@@ -138,11 +150,29 @@ public final class SessionAudio {
|
||||
do {
|
||||
#if os(iOS)
|
||||
if micEnabled {
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||
// affects the built-in route (headphones/BT still win).
|
||||
// NO .defaultToSpeaker here, deliberately. It reads like "prefer the speaker over
|
||||
// the earpiece", and the comment that used to sit here claimed headphones and
|
||||
// Bluetooth still won. That is true of WIRED headphones and false of Bluetooth —
|
||||
// a cable is the one way to test this and see the right answer. It is an
|
||||
// OVERRIDE, and it outranks an A2DP route: with it set, every Bluetooth headset
|
||||
// lost the stream to the phone's own speaker. That is the 0.25 field report ("no
|
||||
// audio over Bluetooth ... plays through speakers if Mic input is enabled") — mic
|
||||
// and echo cancellation both default to ON, so this branch is the DEFAULT path
|
||||
// and every Bluetooth listener hit it; turning the mic off was the accidental
|
||||
// workaround, because that lands on `.playback` below, which routes to A2DP
|
||||
// happily.
|
||||
//
|
||||
// The earpiece problem it was reaching for is real, so it is solved after
|
||||
// activation instead, against the route we were ACTUALLY given —
|
||||
// see `steerBuiltInOutputToSpeaker`.
|
||||
//
|
||||
// `.allowBluetoothA2DP` alone, also deliberately: adding `.allowBluetooth` would
|
||||
// make a headset's MIC usable, but it buys that by dragging the whole route onto
|
||||
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
|
||||
// the built-in mic is the better trade for a game-streaming client.
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
options: [.allowBluetoothA2DP])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
@@ -156,12 +186,66 @@ public final class SessionAudio {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
#if os(iOS)
|
||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||
if micEnabled {
|
||||
steerBuiltInOutputToSpeaker(session)
|
||||
installRouteObserver()
|
||||
}
|
||||
#endif
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `.playAndRecord` parks the BUILT-IN output on the earpiece — right for a phone call,
|
||||
/// useless for a game. Move it to the speaker, but ONLY when the route we were actually given
|
||||
/// is the receiver: anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly
|
||||
/// alone. That "look first" is the whole difference between this and the `.defaultToSpeaker`
|
||||
/// option it replaced, which forced the speaker unconditionally and so beat Bluetooth.
|
||||
///
|
||||
/// Idempotent and cheap, so the route observer can simply call it again.
|
||||
private func steerBuiltInOutputToSpeaker(_ session: AVAudioSession) {
|
||||
// An override already in force shows up as `.builtInSpeaker`, not `.builtInReceiver`, so
|
||||
// re-running this never fights its own previous result.
|
||||
guard session.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver })
|
||||
else { return }
|
||||
do {
|
||||
try session.overrideOutputAudioPort(.speaker)
|
||||
} catch {
|
||||
log.warning("could not move audio off the earpiece: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
|
||||
/// the stream back to the built-in output. iOS drops an output override whenever the route
|
||||
/// changes — which is what lets a newly-connected headset win — so the earpiece steer is a
|
||||
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
|
||||
/// Bluetooth mid-stream would land the game on the earpiece.
|
||||
private func installRouteObserver() {
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(), queue: nil
|
||||
) { [weak self] _ in
|
||||
// Arrives on whatever thread AVFoundation posts it from, and the session API blocks
|
||||
// on the audio server — so do the work on the shared session queue, like every
|
||||
// other call into it.
|
||||
SessionAudio.sessionQueue.async {
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
|
||||
}
|
||||
}
|
||||
stateLock.lock()
|
||||
let stale = routeObserver
|
||||
routeObserver = observer
|
||||
stateLock.unlock()
|
||||
if let stale { NotificationCenter.default.removeObserver(stale) }
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
@@ -249,7 +333,16 @@ public final class SessionAudio {
|
||||
combinedEngine = nil
|
||||
let wasDraining = drainStarted
|
||||
drainStarted = false
|
||||
#if os(iOS)
|
||||
let route = routeObserver
|
||||
routeObserver = nil
|
||||
#endif
|
||||
stateLock.unlock()
|
||||
#if os(iOS)
|
||||
// Before the deactivate below, so a route change during teardown can't re-steer a session
|
||||
// we are in the middle of releasing.
|
||||
if let route { NotificationCenter.default.removeObserver(route) }
|
||||
#endif
|
||||
if let capture {
|
||||
capture.inputNode.removeTap(onBus: 0)
|
||||
capture.stop()
|
||||
|
||||
@@ -112,6 +112,24 @@ public final class GamepadCapture {
|
||||
static let escapeChordElements = [
|
||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||
]
|
||||
/// The stats-overlay chord: Select + X, one tier per completion (off → compact → normal →
|
||||
/// detailed → off). It exists because a controller in both hands has no other way to the
|
||||
/// numbers — the ⌃⌥⇧S combo needs a keyboard and the three-finger tap needs a free screen —
|
||||
/// and on tvOS there is no other way AT ALL, which is what this fixes.
|
||||
///
|
||||
/// Built like Android's mic chord (`GamepadRouter.MIC_CHORD`, Select + Y) and deliberately
|
||||
/// not overlapping `escapeChord`: X is none of its four buttons, so no way of reaching the
|
||||
/// exit chord passes through this one on the way, and vice versa. Select is a menu button
|
||||
/// rather than a twitch action, which keeps the pair out of real play. Y is left free so the
|
||||
/// mic chord can be ported onto it later without moving this one.
|
||||
static let statsChord: UInt32 = GamepadWire.back | GamepadWire.x
|
||||
/// `statsChord`'s elements by GameController alias — same mirror-the-mask rule (and same
|
||||
/// invisible failure) as `escapeChordElements`; the same test pins both.
|
||||
static let statsChordElements = [GCInputButtonOptions, GCInputButtonX]
|
||||
/// Every element some chord reads — what a NON-forwarding slot claims (see `openSlot`). The
|
||||
/// escape chord's four plus the stats chord's X; Select is shared, so it appears once.
|
||||
static let chordElements: [String] =
|
||||
escapeChordElements + statsChordElements.filter { !escapeChordElements.contains($0) }
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
@@ -288,14 +306,15 @@ public final class GamepadCapture {
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
//
|
||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
||||
// complete and the session would have no controller exit at all.)
|
||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the CHORDS are
|
||||
// still read off this slot — on tvOS the escape chord is the only controller way out of
|
||||
// a stream, and the stats chord the only way to the overlay — so their own elements keep
|
||||
// their claim. (Menu especially: leave its gesture attached on tvOS and the press is the
|
||||
// system's — the chord would never complete and the session would have no controller
|
||||
// exit at all.)
|
||||
let claimed = forwarding
|
||||
? Array(c.physicalInputProfile.elements.values)
|
||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
: Self.chordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||
for element in claimed {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
@@ -437,10 +456,24 @@ public final class GamepadCapture {
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
let was = slot.buttons
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = newButtons
|
||||
// The stats chord, edge-triggered on the press that COMPLETES it: one cycle per
|
||||
// chord rather than one per press, since a third button pressed on top finds the
|
||||
// mask already complete and can't re-fire it. Read off the wire mask like the escape
|
||||
// chord, which means a Select the hold-Select gesture has turned into a guide is not
|
||||
// in it — a guide hold can't cycle the overlay on its way past. The buttons still
|
||||
// forward (the chord is a local overlay change, not an input the host must not see).
|
||||
if was & Self.statsChord != Self.statsChord,
|
||||
newButtons & Self.statsChord == Self.statsChord {
|
||||
// Straight to the shared tier default, like TouchMouse's three-finger tap: every
|
||||
// reader (the HUD, the Settings pickers, the live session) observes it through
|
||||
// @AppStorage, so no wiring back to the app is needed.
|
||||
StatsVerbosity.cycle()
|
||||
}
|
||||
}
|
||||
let newAxes: [Int32] = [
|
||||
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||
|
||||
@@ -34,10 +34,26 @@ public final class SiriRemotePointer {
|
||||
private var heldButtons: Set<UInt32> = []
|
||||
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
||||
private var menuDownAt: Date?
|
||||
/// Counts a held Play/Pause down to `statsHold`; nil when the button is up or already
|
||||
/// resolved. See `playPauseChanged`.
|
||||
private var playPauseTimer: Timer?
|
||||
/// The held Play/Pause has already been spent on a stats cycle, so its release must not also
|
||||
/// right-click.
|
||||
private var statsHoldFired = false
|
||||
/// Trails a delivered right-click tap by `tapPress` to release it — see `deliverRightClick`.
|
||||
private var rightReleaseTimer: Timer?
|
||||
|
||||
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
|
||||
/// controller chord's 1.5 s — the remote has no way to trip this during gameplay.
|
||||
private static let disconnectHold: TimeInterval = 1.0
|
||||
/// Hold Play/Pause this long to cycle the stats overlay instead of right-clicking. It is the
|
||||
/// remote's only spare button, and on an Apple TV with no controller in the room this is the
|
||||
/// ONLY route to the numbers (⌃⌥⇧S wants a keyboard, the three-finger tap a touchscreen).
|
||||
/// Shorter than `disconnectHold`: nothing destructive rides on it.
|
||||
private static let statsHold: TimeInterval = 0.5
|
||||
/// pf-client-core's `TAP_PRESS`, borrowed for the deferred right-click: its release trails
|
||||
/// the press by this much, so the two transitions can't fold into nothing downstream.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
|
||||
/// small; two comfortable swipes should cross a 1080p desktop.
|
||||
private static let pointerScale: Float = 1100
|
||||
@@ -95,6 +111,9 @@ public final class SiriRemotePointer {
|
||||
old.buttonX.pressedChangedHandler = nil
|
||||
old.buttonMenu.pressedChangedHandler = nil
|
||||
}
|
||||
// Timers first, then the lift: a tap whose release is still owed is held state, so
|
||||
// `releaseHeld` below is what sends its button-up.
|
||||
cancelPlayPause()
|
||||
releaseHeld()
|
||||
lastTouch = nil
|
||||
menuDownAt = nil
|
||||
@@ -109,12 +128,13 @@ public final class SiriRemotePointer {
|
||||
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
|
||||
}
|
||||
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
|
||||
// Surface click = left button; Play/Pause = right (the remote's only spare face button),
|
||||
// or — held — the stats-overlay cycle. See `playPauseChanged`.
|
||||
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
|
||||
}
|
||||
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
|
||||
MainActor.assumeIsolated { self?.playPauseChanged(pressed: pressed) }
|
||||
}
|
||||
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
||||
@@ -149,6 +169,76 @@ public final class SiriRemotePointer {
|
||||
connection.send(.mouseButton(button, down: down))
|
||||
}
|
||||
|
||||
/// Play/Pause: a TAP right-clicks, a HOLD (`statsHold`) cycles the stats overlay instead.
|
||||
///
|
||||
/// The right button is therefore DEFERRED until the press resolves, rather than going down on
|
||||
/// contact: once the host has seen a button-down there is no taking it back, and a right
|
||||
/// button held for half a second is a context menu on every desktop this streams. The shape
|
||||
/// is the hold-Select gesture's (`GamepadCapture.gestureFiltered`) — suppress, then deliver a
|
||||
/// tap on release or the gesture past the threshold — so the two behave alike.
|
||||
private func playPauseChanged(pressed: Bool) {
|
||||
if pressed {
|
||||
statsHoldFired = false
|
||||
let timer = Timer(timeInterval: Self.statsHold, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.statsHoldElapsed() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = timer
|
||||
return
|
||||
}
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = nil
|
||||
// The hold already spent this press on a cycle — its release clicks nothing.
|
||||
guard !statsHoldFired else {
|
||||
statsHoldFired = false
|
||||
return
|
||||
}
|
||||
deliverRightClick()
|
||||
}
|
||||
|
||||
/// The threshold passed with Play/Pause still down → cycle the overlay and consume the press.
|
||||
/// Writes the shared `statsVerbosity` default every reader observes through @AppStorage — the
|
||||
/// same cycle as ⌃⌥⇧S, the three-finger tap and the controller's Select + X.
|
||||
private func statsHoldElapsed() {
|
||||
playPauseTimer = nil
|
||||
statsHoldFired = true
|
||||
StatsVerbosity.cycle()
|
||||
}
|
||||
|
||||
/// A Play/Pause tap, delivered now that it resolved as one: the right button down, its
|
||||
/// release `tapPress` behind so the pair can't collapse into nothing downstream.
|
||||
private func deliverRightClick() {
|
||||
// A previous tap's owed release goes out FIRST — two taps inside `tapPress` would
|
||||
// otherwise send the host two downs in a row (the rule GamepadCapture's held-back Select
|
||||
// tap follows for the same reason).
|
||||
finishRightClick()
|
||||
setButton(3, down: true)
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.finishRightClick() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
rightReleaseTimer = timer
|
||||
}
|
||||
|
||||
/// Release a tap's right button if one is still owed; nothing otherwise.
|
||||
private func finishRightClick() {
|
||||
guard rightReleaseTimer != nil else { return }
|
||||
rightReleaseTimer?.invalidate()
|
||||
rightReleaseTimer = nil
|
||||
setButton(3, down: false)
|
||||
}
|
||||
|
||||
/// Drop any in-flight Play/Pause state (unbind / stop). Timers only — a right button already
|
||||
/// sent down is held state, and `releaseHeld` is what lifts it.
|
||||
private func cancelPlayPause() {
|
||||
playPauseTimer?.invalidate()
|
||||
playPauseTimer = nil
|
||||
rightReleaseTimer?.invalidate()
|
||||
rightReleaseTimer = nil
|
||||
statsHoldFired = false
|
||||
}
|
||||
|
||||
private func menuChanged(pressed: Bool) {
|
||||
if pressed {
|
||||
menuDownAt = Date()
|
||||
|
||||
@@ -5,8 +5,9 @@ import XCTest
|
||||
|
||||
/// The escape chord's mask and its GameController alias list have to describe the same four
|
||||
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
||||
/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops
|
||||
/// covering the mask, the missing button's press stays the system's and the chord never completes.
|
||||
/// is on, but only of `chordElements` — `escapeChordElements` plus the stats chord's — while it is
|
||||
/// off, so if this alias list ever stops covering the mask, the missing button's press stays the
|
||||
/// system's and the chord never completes. (`GamepadStatsChordTests` pins the claim list itself.)
|
||||
///
|
||||
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
||||
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import GameController
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The stats chord (Select + X) has the same drift hazard as the escape chord it sits beside: its
|
||||
/// mask and its GameController alias list must describe the same buttons, and every element some
|
||||
/// chord reads has to appear in the list a NON-forwarding slot claims — otherwise that button's
|
||||
/// press stays the system's and the chord silently never completes.
|
||||
///
|
||||
/// It matters most on tvOS, where this is the only way to the statistics overlay at all (no
|
||||
/// keyboard for ⌃⌥⇧S, no touchscreen for the three-finger tap). The failure looks like nothing
|
||||
/// happening, so it is pinned here rather than left to the comments.
|
||||
@MainActor
|
||||
final class GamepadStatsChordTests: XCTestCase {
|
||||
|
||||
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
||||
private let pairing: [(alias: String, bit: UInt32)] = [
|
||||
(GCInputButtonOptions, GamepadWire.back),
|
||||
(GCInputButtonX, GamepadWire.x),
|
||||
]
|
||||
|
||||
func testChordMaskIsExactlyTheTwoPairedButtons() {
|
||||
XCTAssertEqual(
|
||||
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
||||
GamepadCapture.statsChord,
|
||||
"the chord mask and the alias pairing describe different buttons")
|
||||
}
|
||||
|
||||
func testAliasListMirrorsTheMask() {
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.statsChordElements.count,
|
||||
GamepadCapture.statsChord.nonzeroBitCount,
|
||||
"alias list and chord mask differ in size")
|
||||
XCTAssertEqual(GamepadCapture.statsChordElements, pairing.map(\.alias))
|
||||
}
|
||||
|
||||
/// The two chords must not be reachable through one another: pressing toward the exit chord
|
||||
/// may not cycle the overlay on the way, and holding the stats chord may not arm a disconnect.
|
||||
/// Select is the one button they share by design — everything else has to be disjoint.
|
||||
func testChordsOverlapOnlyOnSelect() {
|
||||
XCTAssertEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord,
|
||||
GamepadWire.back,
|
||||
"the stats and escape chords share a button other than Select")
|
||||
// Neither is a subset of the other, so completing one can never complete the other.
|
||||
XCTAssertNotEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.statsChord)
|
||||
XCTAssertNotEqual(
|
||||
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.escapeChord)
|
||||
}
|
||||
|
||||
/// `chordElements` is what `openSlot` claims when forwarding is OFF. It must cover BOTH
|
||||
/// chords' aliases and repeat none of them (a duplicate would mean a bit with no element).
|
||||
func testClaimListCoversBothChordsWithoutDuplicates() {
|
||||
let claim = GamepadCapture.chordElements
|
||||
for alias in GamepadCapture.escapeChordElements + GamepadCapture.statsChordElements {
|
||||
XCTAssertTrue(claim.contains(alias), "\(alias) is read by a chord but never claimed")
|
||||
}
|
||||
XCTAssertEqual(Set(claim).count, claim.count, "a repeated alias in the claim list")
|
||||
// Shared Select means the union is one shorter than the two lists laid end to end.
|
||||
XCTAssertEqual(
|
||||
claim.count,
|
||||
GamepadCapture.escapeChordElements.count + GamepadCapture.statsChordElements.count - 1)
|
||||
}
|
||||
|
||||
/// A cycle is a pure rotation through the four tiers — the chord fires `StatsVerbosity.cycle`,
|
||||
/// and a tier that dead-ended would strand a tvOS user with no other way back.
|
||||
func testCycleReachesEveryTierAndReturns() {
|
||||
var tier = StatsVerbosity.off
|
||||
var seen: [StatsVerbosity] = []
|
||||
for _ in 0..<StatsVerbosity.allCases.count {
|
||||
seen.append(tier)
|
||||
tier = tier.next()
|
||||
}
|
||||
XCTAssertEqual(Set(seen).count, StatsVerbosity.allCases.count, "a tier is unreachable")
|
||||
XCTAssertEqual(tier, .off, "the cycle does not return to where it started")
|
||||
}
|
||||
}
|
||||
+111
-26
@@ -303,6 +303,58 @@ def _native_client() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# The one architecture the flatpak client is built for.
|
||||
_FLATPAK_ARCH = "x86_64"
|
||||
|
||||
|
||||
def _flatpak_ref() -> dict | None:
|
||||
"""The INSTALLED client flatpak resolved to a SCOPE and a BRANCH, or None when there is none.
|
||||
|
||||
``{"scope": "--user"|"--system", "branch": "canary", "ref": "io.unom.Punktfunk//canary"}``.
|
||||
|
||||
⭐⭐ **Naming no branch is not a shorthand for "the only one".** flatpak refuses an ambiguous
|
||||
ref rather than guessing at one, and the ambiguity does not need two branches *installed*:
|
||||
the punktfunk remote publishes `stable` AND `canary`, so an unqualified
|
||||
``flatpak remote-info <origin> io.unom.Punktfunk`` errors with "Multiple branches available"
|
||||
on a Deck that has exactly one. That error is why the client update check silently answered
|
||||
"up to date" on every Deck — so every query downstream now names the ref in full.
|
||||
|
||||
Read off the exported tree rather than by shelling out to ``flatpak list``, because
|
||||
:func:`_client_argv` is on the path of every headless call and a subprocess per call would be
|
||||
absurd (the same reason :func:`_flatpak_installed` reads the filesystem). ``active`` is the
|
||||
symlink flatpak points at the deployed commit — its presence is what makes a branch directory
|
||||
an INSTALL rather than the leftovers of one.
|
||||
|
||||
With more than one branch installed, `stable` wins, because that is the branch a plain
|
||||
``flatpak run`` resolves to: the check has to describe the client the launcher really starts,
|
||||
or a stale `stable` silently beats a current `canary` in both places at once.
|
||||
"""
|
||||
if not _flatpak():
|
||||
return None
|
||||
for root, scope in (
|
||||
(Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak", "--user"),
|
||||
(Path("/var/lib/flatpak"), "--system"),
|
||||
):
|
||||
try:
|
||||
branches = sorted(
|
||||
p.name for p in (root / "app" / APP_ID / _FLATPAK_ARCH).iterdir()
|
||||
if (p / "active").exists()
|
||||
)
|
||||
except OSError:
|
||||
continue # not installed in this scope
|
||||
if not branches:
|
||||
continue
|
||||
branch = "stable" if "stable" in branches else branches[0]
|
||||
if len(branches) > 1:
|
||||
decky.logger.warning(
|
||||
"%s is installed on %d branches (%s) — using %s, the one `flatpak run` resolves "
|
||||
"to; uninstall the others so the client you launch is the client we update",
|
||||
APP_ID, len(branches), ", ".join(branches), branch,
|
||||
)
|
||||
return {"scope": scope, "branch": branch, "ref": f"{APP_ID}//{branch}"}
|
||||
return None
|
||||
|
||||
|
||||
def _flatpak_installed() -> bool:
|
||||
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
|
||||
|
||||
@@ -310,10 +362,7 @@ def _flatpak_installed() -> bool:
|
||||
because this is on the path of every headless call and a subprocess per call would be absurd.
|
||||
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
|
||||
"""
|
||||
if not _flatpak():
|
||||
return False
|
||||
user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID
|
||||
return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists()
|
||||
return _flatpak_ref() is not None
|
||||
|
||||
|
||||
def _client_argv() -> list[str] | None:
|
||||
@@ -323,15 +372,21 @@ def _client_argv() -> list[str] | None:
|
||||
behaving exactly as it did. A native binary is the fallback — and on a machine with no
|
||||
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
|
||||
forces one when a machine has both.
|
||||
|
||||
The branch is PINNED (`--branch=`, which keeps the app id last — :func:`_cli_argv` appends
|
||||
`--command=` and flatpak treats everything after the id as the app's own argv), so the client
|
||||
this launches is the exact ref :func:`_client_update_state` checks and :meth:`Plugin.
|
||||
update_client` updates.
|
||||
"""
|
||||
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
|
||||
native = _native_client()
|
||||
if forced == "native":
|
||||
return [native] if native else None
|
||||
if forced != "flatpak" and not _flatpak_installed() and native:
|
||||
ref = _flatpak_ref()
|
||||
if forced != "flatpak" and not ref and native:
|
||||
return [native]
|
||||
if _flatpak_installed():
|
||||
return [_flatpak(), "run", "--arch=x86_64", APP_ID]
|
||||
if ref:
|
||||
return [_flatpak(), "run", f"--arch={_FLATPAK_ARCH}", f"--branch={ref['branch']}", APP_ID]
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
@@ -575,27 +630,44 @@ def _looks_outdated(stderr: str) -> bool:
|
||||
|
||||
|
||||
async def _client_update_state() -> dict:
|
||||
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
|
||||
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
||||
it versions independently of this plugin — so we compare the installed commit against the
|
||||
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
||||
(not installed, no flatpak, offline).
|
||||
"""Is a newer commit of the flatpak client available in the remote it tracks? The client
|
||||
versions independently of this plugin, so we compare the installed commit against the
|
||||
remote's here and let the QAM offer an update in the scope the client is actually installed
|
||||
in — a per-user install is one ``sudo flatpak update`` (system-scope) never reaches.
|
||||
|
||||
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
|
||||
flatpak built from main between releases carries the release's crate version, so the
|
||||
signed-manifest comparison the native path uses would call it up to date when it isn't.
|
||||
Native installs have no commit to compare and go through :func:`_native_update_state`."""
|
||||
state = {"available": False, "installed": "", "remote": ""}
|
||||
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
Native installs have no commit to compare and go through :func:`_native_update_state`.
|
||||
|
||||
⚠ Every query names the ref IN FULL (see :func:`_flatpak_ref`) — the remote publishes both
|
||||
`stable` and `canary`, and an unqualified one is an error, not a default."""
|
||||
state = {"available": False, "installed": "", "remote": "", "error": ""}
|
||||
ref = _flatpak_ref()
|
||||
if not ref:
|
||||
return state # no flatpak client in either scope
|
||||
scope, full = ref["scope"], ref["ref"]
|
||||
rc, info = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||
if rc != 0:
|
||||
return state # client not installed as a user app / no flatpak
|
||||
decky.logger.warning("flatpak info %s %s failed (rc=%s): %s", scope, full, rc, info[-200:])
|
||||
state["error"] = "client-unavailable"
|
||||
return state
|
||||
state["installed"] = _field_from(info, "Commit")
|
||||
origin = _field_from(info, "Origin")
|
||||
if not origin:
|
||||
state["error"] = "no-origin" # a sideloaded bundle tracks no remote to compare against
|
||||
return state
|
||||
rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0)
|
||||
rc, rinfo = await _flatpak_capture(["remote-info", scope, origin, full], timeout=25.0)
|
||||
if rc != 0:
|
||||
return state # remote unreachable — treat as "up to date", retry next check
|
||||
# ⭐ NOT "up to date". Silently swallowing this is precisely how the whole leg stayed
|
||||
# broken in the field: an unqualified ref made every one of these calls fail, and
|
||||
# returning `available=False` dressed the failure up as good news. A check that could
|
||||
# not run says so, and the panel says so too.
|
||||
decky.logger.warning(
|
||||
"flatpak remote-info %s %s failed (rc=%s): %s", origin, full, rc, rinfo.strip()[-200:]
|
||||
)
|
||||
state["error"] = "fetch-failed"
|
||||
return state
|
||||
state["remote"] = _field_from(rinfo, "Commit")
|
||||
state["available"] = bool(
|
||||
state["installed"] and state["remote"] and state["installed"] != state["remote"]
|
||||
@@ -946,8 +1018,9 @@ class Plugin:
|
||||
async def update_client(self) -> dict:
|
||||
"""Update the **client**, by whichever route this box's install actually supports.
|
||||
|
||||
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
|
||||
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
|
||||
* **flatpak** — ``flatpak update`` against the FULL ref, in the scope the client is
|
||||
installed in (a per-user install is one ``sudo flatpak update`` never reaches, and an
|
||||
unqualified ref is an error on a remote publishing more than one branch).
|
||||
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
||||
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
||||
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
||||
@@ -960,18 +1033,22 @@ class Plugin:
|
||||
"""
|
||||
if not _client_is_flatpak():
|
||||
return await self._update_native_client()
|
||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
ref = _flatpak_ref()
|
||||
if not ref:
|
||||
return {"ok": False, "updated": False, "error": "client-unavailable"}
|
||||
scope, full = ref["scope"], ref["ref"]
|
||||
_, before = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||
before_commit = _field_from(before, "Commit")
|
||||
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
||||
rc, out = await _flatpak_capture(["update", scope, "-y", full], timeout=300.0)
|
||||
if rc != 0:
|
||||
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
|
||||
return {"ok": False, "updated": False, "error": "update-failed"}
|
||||
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
_, after = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||
after_commit = _field_from(after, "Commit")
|
||||
updated = bool(before_commit and after_commit and before_commit != after_commit)
|
||||
decky.logger.info(
|
||||
"flatpak client update: %s -> %s (updated=%s)",
|
||||
before_commit[:10], after_commit[:10], updated,
|
||||
"flatpak client update (%s %s): %s -> %s (updated=%s)",
|
||||
scope, full, before_commit[:10], after_commit[:10], updated,
|
||||
)
|
||||
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
||||
return {"ok": True, "updated": updated}
|
||||
@@ -1018,12 +1095,20 @@ class Plugin:
|
||||
try:
|
||||
if _client_is_flatpak():
|
||||
cu = await _client_update_state()
|
||||
ref = _flatpak_ref()
|
||||
result["client_update_available"] = bool(cu["available"])
|
||||
result["client_current"] = (cu["installed"] or "")[:10]
|
||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||
result["client_install"] = "flatpak"
|
||||
result["client_applier"] = "flatpak"
|
||||
result["client_command"] = f"flatpak update --user {APP_ID}"
|
||||
# The line a user could actually run — same scope, same full ref we use. The old
|
||||
# unqualified one errored out ("Multiple branches available") when pasted, too.
|
||||
result["client_command"] = (
|
||||
f"flatpak update {ref['scope']} -y {ref['ref']}" if ref else ""
|
||||
)
|
||||
if cu["error"]:
|
||||
# Same contract as the native leg: "couldn't tell" is never "up to date".
|
||||
result["client_error"] = cu["error"]
|
||||
else:
|
||||
nu = await _native_update_state()
|
||||
result["client_update_available"] = bool(nu.get("update_available"))
|
||||
|
||||
@@ -30,6 +30,10 @@ sys.modules["decky"] = decky
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import main # noqa: E402 (the plugin backend)
|
||||
|
||||
# The argv fixtures below monkey-patch `_client_argv` to pin one install shape; the
|
||||
# _flatpak_ref block wants the REAL resolver back, so keep a handle on it.
|
||||
_real_client_argv = main._client_argv
|
||||
|
||||
failures = 0
|
||||
|
||||
|
||||
@@ -75,6 +79,60 @@ check("cli argv: native without a sibling CLI is None", main._cli_argv() is None
|
||||
(tmp / "punktfunk").write_text("")
|
||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||
|
||||
# ---- _flatpak_ref: the branch must be NAMED, always ---------------------------------------
|
||||
#
|
||||
# The bug this exists to prevent: every client-update query used to name no branch, and the
|
||||
# punktfunk remote publishes `stable` AND `canary` — so `flatpak remote-info <origin>
|
||||
# io.unom.Punktfunk` failed with "Multiple branches available", the check swallowed the failure,
|
||||
# and the panel reported the client up to date forever. One branch INSTALLED is not enough to
|
||||
# make the query unambiguous; the ambiguity lives on the remote.
|
||||
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
|
||||
_fp_root = Path("/tmp/pf-test-home/.local/share/flatpak/app/io.unom.Punktfunk/x86_64")
|
||||
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||
main._client_argv = _real_client_argv # undo the fixture patches above
|
||||
|
||||
check("ref: nothing installed => None", main._flatpak_ref() is None)
|
||||
|
||||
|
||||
def _install_branch(name: str):
|
||||
"""A deployed branch: the `active` symlink is what distinguishes an install from leftovers."""
|
||||
commit = _fp_root / name / "deadbeef"
|
||||
commit.mkdir(parents=True, exist_ok=True)
|
||||
(_fp_root / name / "active").symlink_to("deadbeef")
|
||||
|
||||
|
||||
(_fp_root / "canary").mkdir(parents=True, exist_ok=True)
|
||||
check("ref: a branch dir without `active` is leftovers, not an install", main._flatpak_ref() is None)
|
||||
|
||||
_install_branch("canary")
|
||||
ref = main._flatpak_ref()
|
||||
check("ref: the single installed branch is used", ref == {
|
||||
"scope": "--user", "branch": "canary", "ref": "io.unom.Punktfunk//canary",
|
||||
})
|
||||
check(
|
||||
"ref: the launcher pins that branch, app id still LAST",
|
||||
main._client_argv() == [
|
||||
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary", "io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
# The pin must survive _cli_argv's rewrite, or the CLI runs a different build than the GUI.
|
||||
check(
|
||||
"ref: --command= is inserted before the app id, keeping the pin",
|
||||
main._cli_argv() == [
|
||||
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary",
|
||||
"--command=punktfunk", "io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
|
||||
# Two installed: `stable` is what a plain `flatpak run` resolves to, so it must be what we
|
||||
# check and update too — otherwise a leftover stale `stable` wins the launch while `canary`
|
||||
# gets the update, and the two halves disagree about which client is even running.
|
||||
_install_branch("stable")
|
||||
check("ref: with both installed, stable wins (what `flatpak run` picks)",
|
||||
main._flatpak_ref()["branch"] == "stable")
|
||||
|
||||
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
|
||||
|
||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||
#
|
||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||
|
||||
@@ -120,7 +120,9 @@ export interface UpdateInfo {
|
||||
client_applier: string;
|
||||
client_command: string; // one copy-pastable line that updates this install by hand
|
||||
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
|
||||
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
|
||||
// The client check couldn't complete — NEVER rendered as "up to date". "client-outdated" |
|
||||
// "client-unavailable" | "no-origin" | "fetch-failed" (flatpak: the remote was unreachable).
|
||||
client_error?: string;
|
||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
clientUpdateIsOneTap,
|
||||
hasUpdate,
|
||||
HostView,
|
||||
needsPair,
|
||||
@@ -183,8 +184,11 @@ const QamPanel: FC = () => {
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin v${update!.current} → v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
? // "+ client" only when this tap will really install it. A manual-only
|
||||
// client rides along as a toast with the command, and promising it in the
|
||||
// label would make that read as a failure.
|
||||
`Plugin v${update!.current} → v${update!.latest}${
|
||||
clientUpdateIsOneTap(update) ? " + client" : ""
|
||||
}`
|
||||
: "New client version"
|
||||
}
|
||||
|
||||
@@ -129,16 +129,6 @@ struct Args {
|
||||
/// host must composite the metadata cursor on its own; decode the dump and look for the
|
||||
/// pointer.
|
||||
cursor_nochannel: bool,
|
||||
/// `--cursor-hold` — with `--cursor-capture`/`--cursor-nochannel`, stop the relative wiggle
|
||||
/// after a short priming burst instead of circling forever. The wiggle exists to keep a
|
||||
/// damage-driven desktop publishing frames, but it also DRAGS the host pointer several hundred
|
||||
/// pixels a second, which makes it impossible to hold the pointer over a chosen target — and
|
||||
/// the shape under the pointer is the whole point when the question is "does the MONOCHROME
|
||||
/// I-beam survive compositing?" (the arrow is a colour cursor and proves nothing about the
|
||||
/// mono path). With this flag: prime for ~3 s so the pointer is un-suppressed and metadata is
|
||||
/// flowing, then hold still so a `SetCursorPos` on the host can park it on a text field for
|
||||
/// the rest of the dump.
|
||||
cursor_hold: bool,
|
||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
@@ -319,7 +309,6 @@ fn parse_args() -> Args {
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
|
||||
cursor_hold: argv.iter().any(|a| a == "--cursor-hold"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,23 +900,13 @@ async fn session(args: Args) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let wiggle_conn = conn.clone();
|
||||
let hold = args.cursor_hold;
|
||||
tokio::spawn(async move {
|
||||
// Relative circles: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump — unless
|
||||
// `--cursor-hold`, which primes and then stops so the pointer can be parked.
|
||||
// Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
tracing::info!(hold, "cursor-capture: relative pointer wiggle running");
|
||||
let prime_until = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
tracing::info!("cursor-capture: relative pointer wiggle running");
|
||||
let mut t = 0.0f64;
|
||||
loop {
|
||||
if hold && std::time::Instant::now() >= prime_until {
|
||||
tracing::info!(
|
||||
"cursor-capture: wiggle primed and STOPPED (--cursor-hold) — the pointer \
|
||||
now stays where the host puts it"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let e = InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
|
||||
+46
-24
@@ -325,6 +325,22 @@ mod session_main {
|
||||
};
|
||||
// Before the struct literal — `vulkan` moves into it below.
|
||||
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
|
||||
// …and the 4:4:4 promise, for the same reason: asked while the device bundle is
|
||||
// still borrowable. `&&` short-circuits, so a box that never enabled Full chroma
|
||||
// pays no capability queries for a feature it does not want.
|
||||
let want_444 = settings.enable_444
|
||||
&& pf_client_core::video::hevc_444_hardware_decodable(vulkan.as_ref());
|
||||
if settings.enable_444 && !want_444 {
|
||||
// Loud, because the user turned a switch on and is not getting it. The
|
||||
// alternative is what this replaces: the host grants 4:4:4, the decode ladder
|
||||
// has no rung that can take it, and the session drops HEVC entirely.
|
||||
tracing::warn!(
|
||||
"Full chroma (4:4:4) requested but this device has no 4:4:4 HEVC decode — \
|
||||
asking for 4:2:0 instead. Advertising it would cost the whole codec: 4:4:4 \
|
||||
is granted on HEVC only, and there is no software HEVC decoder to fall back \
|
||||
to (PyroWave carries 4:4:4 on any GPU, if the link can take it)."
|
||||
);
|
||||
}
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
@@ -356,30 +372,16 @@ mod session_main {
|
||||
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
||||
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
|
||||
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit says
|
||||
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
|
||||
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
||||
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
|
||||
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
|
||||
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
|
||||
// with the decoder ladder demoting on its own. No capability probe gates the
|
||||
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
|
||||
// refuses anything else rather than mis-scaling it, so on a box whose hardware
|
||||
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
|
||||
// Welcome BEFORE we build a decoder. It is now ALSO gated on this device being
|
||||
// able to decode 4:4:4 (`want_444`, computed above); the rule and its reasoning
|
||||
// live in `video::video_caps_for`, which is where they get tested.
|
||||
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
|
||||
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
||||
// frames actually took.
|
||||
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
| if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
}
|
||||
| if settings.enable_444 {
|
||||
punktfunk_core::quic::VIDEO_CAP_444
|
||||
} else {
|
||||
0
|
||||
},
|
||||
video_caps: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
|
||||
// This panel's HDR colour volume → the host's virtual-display EDID, so host
|
||||
// apps tone-map to the real glass. Windows reads it from DXGI (the
|
||||
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
|
||||
@@ -496,6 +498,12 @@ mod session_main {
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
|
||||
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
|
||||
///
|
||||
/// ⚠⚠ Called from the TOP of [`run`], ahead of the `--list-adapters` / `--probe-decode`
|
||||
/// early exits — not merely "before `run_session` creates the instance". Those flags
|
||||
/// create Vulkan instances of their own and RADV latches `RADV_PERFTEST` when its ICD
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
@@ -579,6 +587,23 @@ mod session_main {
|
||||
)
|
||||
.init();
|
||||
|
||||
// Before ANY Vulkan call — and that includes the two probe flags below, which is the
|
||||
// whole reason this sits at the top of `run` instead of beside the session setup it
|
||||
// was written for. Make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated
|
||||
// RADV). Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
//
|
||||
// ⚠⚠ It USED to sit after the `--list-adapters` / `--probe-decode` / `--list-audio` /
|
||||
// `--pair` early exits, which meant the triage tool answered a DIFFERENT question from
|
||||
// the one the streaming path asks. Measured on a Steam Deck (2026-08-08, canary
|
||||
// `e22af40f`), same binary, back to back: bare `--probe-decode` printed `vulkan video
|
||||
// decode: no`, `driver decode ops: none (0x0)`, `no queue family advertises
|
||||
// VIDEO_DECODE`; the same call with `RADV_PERFTEST=video_decode` in the environment
|
||||
// printed `YES` and `H.264, H.265, AV1, VP9`. The tool exists to be believed, so any
|
||||
// Deck triage that consulted it reached the opposite of the truth.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
|
||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
||||
if arg_flag("--list-adapters") {
|
||||
@@ -753,11 +778,8 @@ mod session_main {
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
|
||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
// (The RADV video-decode opt-in that used to live here now runs at the very top of
|
||||
// `run` — it has to precede the probe flags too, not just the session.)
|
||||
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
|
||||
@@ -168,6 +168,10 @@ pub struct PortalCapturer {
|
||||
/// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild
|
||||
/// retries on the CPU offer instead of failing identically forever.
|
||||
vaapi_dmabuf: bool,
|
||||
/// PW3: this capture's dmabuf offer has been confirmed to negotiate (a frame arrived), so the
|
||||
/// negotiation retry budget has already been credited back. One-shot — the credit is per
|
||||
/// capture, not per frame.
|
||||
negotiation_confirmed: bool,
|
||||
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
|
||||
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
|
||||
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
|
||||
@@ -412,6 +416,7 @@ impl PwHandles {
|
||||
signals: self.signals,
|
||||
stall_since: None,
|
||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||
negotiation_confirmed: false,
|
||||
hdr_offer: self.hdr_offer,
|
||||
hdr_source,
|
||||
node_id,
|
||||
@@ -468,6 +473,13 @@ fn spawn_pipewire(
|
||||
} else {
|
||||
want_hdr
|
||||
};
|
||||
// PW3: tell the raw-dmabuf latch which capture this is BEFORE reading its verdict below. A
|
||||
// different node id is a different question — a fresh virtual output, a compositor restart,
|
||||
// the Bazzite Gaming↔Desktop switch — and inheriting "dmabuf does not work here" from an
|
||||
// unrelated capture is how one transient timeout used to cost a host CPU capture until it was
|
||||
// restarted. The portal bit is in the key because a portal-fd capture and a virtual-output
|
||||
// capture with the same node number are genuinely different sources.
|
||||
pf_zerocopy::note_raw_dmabuf_capture(u64::from(node_id) | (u64::from(fd.is_some()) << 32));
|
||||
// THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1).
|
||||
// Every environment/latch read the decision depends on happens at this single point.
|
||||
let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs {
|
||||
@@ -705,6 +717,7 @@ impl PortalCapturer {
|
||||
// The slot before the wakeup: a publish that coalesced its edge (or landed while we were
|
||||
// not waiting) is still visible here.
|
||||
if let Some(f) = self.take_frame() {
|
||||
self.note_negotiation_confirmed();
|
||||
return Ok(f);
|
||||
}
|
||||
let slice = Duration::from_millis(500)
|
||||
@@ -728,6 +741,16 @@ impl PortalCapturer {
|
||||
self.slot.lock().ok().and_then(|mut s| s.take())
|
||||
}
|
||||
|
||||
/// PW3: a frame arrived, so this capture's dmabuf-only offer DID negotiate — credit the
|
||||
/// negotiation retry budget back. Only meaningful for a capture that actually made that offer,
|
||||
/// and only once per capture (the budget counts consecutive failed BUILDS, not frames).
|
||||
fn note_negotiation_confirmed(&mut self) {
|
||||
if self.vaapi_dmabuf && !self.negotiation_confirmed {
|
||||
self.negotiation_confirmed = true;
|
||||
pf_zerocopy::note_raw_dmabuf_negotiation_ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
fn next_frame_timed_out(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -288,16 +288,57 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
|
||||
///
|
||||
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
|
||||
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
|
||||
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
|
||||
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
|
||||
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
|
||||
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
|
||||
///
|
||||
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
|
||||
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
|
||||
/// and the link silently stalls in "negotiating" — the exact failure mode the cursor-meta `size`
|
||||
/// property already cost this codebase once (see `build_cursor_meta_param`). With a range the
|
||||
/// producer clamps into it and negotiation still succeeds.
|
||||
///
|
||||
/// The numbers: `min` stays at 2 so nothing that works today stops working; `default` 8 is ~133 ms
|
||||
/// of buffer at 60 Hz and ~33 ms at 240 Hz, comfortably past the ~3-4 ms capture→fence latency
|
||||
/// measured in PW3/PW4 even with a second frame in flight; `max` 16 is a ceiling, not a request
|
||||
/// (a 4K 4:4:4 buffer is ~25 MB, so 16 is ~400 MB of compositor allocation and worth capping).
|
||||
/// **What the producer actually picks is logged by the stage-1 census — trust that line, not
|
||||
/// these constants.**
|
||||
const POOL_MIN: i32 = 2;
|
||||
const POOL_DEFAULT: i32 = 8;
|
||||
const POOL_MAX: i32 = 16;
|
||||
|
||||
/// Build a Buffers param requesting dmabuf-only buffers, with pool headroom (see [`POOL_DEFAULT`]).
|
||||
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
}],
|
||||
properties: vec![
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
},
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_buffers,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: POOL_DEFAULT,
|
||||
min: POOL_MIN,
|
||||
max: POOL_MAX,
|
||||
},
|
||||
),
|
||||
)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -512,4 +553,47 @@ mod tests {
|
||||
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||
);
|
||||
}
|
||||
|
||||
/// PW5 stage 2: the pool request must be a **Choice Range**, never a fixed Int.
|
||||
///
|
||||
/// This is the whole safety argument for asking at all: SPA intersects the two sides' Buffers
|
||||
/// params, so a fixed count a producer cannot afford empties the intersection and the link
|
||||
/// stalls in "negotiating" with no error anywhere — the same trap that cost this codebase the
|
||||
/// entire Linux cursor channel once (see `build_cursor_meta_param`). Asserting the pod shape
|
||||
/// is what keeps a later "simplify" from turning the range back into a number.
|
||||
#[test]
|
||||
fn the_dmabuf_pool_request_is_a_range_not_a_fixed_count() {
|
||||
let pod = build_dmabuf_buffers().unwrap();
|
||||
let key = spa::sys::SPA_PARAM_BUFFERS_buffers.to_ne_bytes();
|
||||
let at = pod
|
||||
.windows(4)
|
||||
.position(|w| w == key)
|
||||
.expect("the dmabuf Buffers pod must carry a buffers count");
|
||||
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
|
||||
// Property = { key, flags, value_pod }; value_pod = { size, type, body }. A Choice body
|
||||
// is { type: u32, flags: u32, child_size: u32, child_type: u32, values… }.
|
||||
assert_eq!(
|
||||
word(at + 12),
|
||||
spa::sys::SPA_TYPE_Choice,
|
||||
"the buffers count must be a Choice, not a bare Int — a fixed count can fail \
|
||||
negotiation outright"
|
||||
);
|
||||
assert_eq!(
|
||||
word(at + 16),
|
||||
spa::sys::SPA_CHOICE_Range,
|
||||
"the Choice must be a Range (default, min, max)"
|
||||
);
|
||||
assert_eq!(word(at + 24), 4, "Choice child pods are 4-byte Ints");
|
||||
assert_eq!(word(at + 28), spa::sys::SPA_TYPE_Int, "…of type Int");
|
||||
let vals: Vec<i32> = (0..3)
|
||||
.map(|i| i32::from_ne_bytes(pod[at + 32 + i * 4..at + 36 + i * 4].try_into().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vals,
|
||||
vec![POOL_DEFAULT, POOL_MIN, POOL_MAX],
|
||||
"Range values are serialized default-first"
|
||||
);
|
||||
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
|
||||
const { assert!(POOL_MIN <= 2) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,47 +212,6 @@ struct KeyedMutexGuard<'a> {
|
||||
/// (`frame_transport.rs`).
|
||||
const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080;
|
||||
|
||||
/// First retry delay after a composite-blend failure — short enough that a transient device-loss
|
||||
/// costs a few pointer-less frames rather than the rest of the session.
|
||||
const BLEND_RETRY_MIN: Duration = Duration::from_millis(250);
|
||||
/// Ceiling for the doubling retry: a genuinely broken device stops burning a frame-sized texture
|
||||
/// allocation every quarter second, while still recovering within ~4 s if it ever comes back.
|
||||
const BLEND_RETRY_MAX: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long the poller may publish NOTHING before the capturer calls it wedged. It polls at
|
||||
/// `CursorPoller::INTERVAL` (4 ms), so this is ~250 missed publishes — far outside any scheduling
|
||||
/// hiccup, and still fast enough to name the fault while a user is still looking at it.
|
||||
const POLLER_STALL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The next retry delay after a composite-blend failure: [`BLEND_RETRY_MIN`] for the first, then
|
||||
/// doubling per consecutive failure up to [`BLEND_RETRY_MAX`]. Free function so the escalation is
|
||||
/// testable without a live D3D11 device (the `mono_planes_to_rgba` precedent — the arithmetic a
|
||||
/// bug would hide in does not need the plumbing around it).
|
||||
fn next_blend_backoff(prev: Option<Duration>) -> Duration {
|
||||
prev.map_or(BLEND_RETRY_MIN, |b| (b * 2).min(BLEND_RETRY_MAX))
|
||||
}
|
||||
|
||||
/// The composite-regen change key for an overlay: what a blend would DRAW — `(serial, x, y)` for a
|
||||
/// visible pointer, `None` when nothing would be drawn. ONE definition, used by both the regen test
|
||||
/// and the blend itself, because the two drifting apart is precisely the bug shape here: a key that
|
||||
/// says "changed" while the drawn frame is identical re-encodes for nothing, and a key that says
|
||||
/// "unchanged" while the pointer moved freezes it on screen.
|
||||
fn blend_key_of(ov: Option<&pf_frame::CursorOverlay>) -> Option<(u64, i32, i32)> {
|
||||
ov.filter(|o| o.visible).map(|o| (o.serial, o.x, o.y))
|
||||
}
|
||||
|
||||
/// A composite-blend failure and its pending retry ([`IddPushCapturer::blend_fail`]).
|
||||
struct BlendFail {
|
||||
/// No blend is attempted before this instant.
|
||||
retry_at: Instant,
|
||||
/// The delay that produced `retry_at`; doubles per consecutive failure up to
|
||||
/// [`BLEND_RETRY_MAX`].
|
||||
backoff: Duration,
|
||||
/// Consecutive failures without an intervening success — logged, so a session that is
|
||||
/// permanently pointer-less is distinguishable from one that hiccupped once.
|
||||
consecutive: u32,
|
||||
}
|
||||
|
||||
impl<'a> KeyedMutexGuard<'a> {
|
||||
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
|
||||
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
|
||||
@@ -426,26 +385,13 @@ pub struct IddPushCapturer {
|
||||
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
|
||||
/// it off (there is no channel to hand the pointer to).
|
||||
composite_forced: bool,
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` before the first blend and
|
||||
/// after a failure dropped it; rebuilt on the next attempt that is not suppressed.
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
|
||||
/// composite mode then degrades to pointer-less frames (warned once).
|
||||
cursor_blend: Option<cursor_blend::CursorBlendPass>,
|
||||
/// Composite-blend failure state. `None` = healthy. A failure used to be TERMINAL — one warn,
|
||||
/// a sticky flag, and the session then streamed a pointer-less desktop for its whole life —
|
||||
/// but the causes that actually occur (device loss, a transient allocation failure on the
|
||||
/// frame-sized scratch) heal, and the pointer is the one thing a capture-model session cannot
|
||||
/// do without. So a failure now only suppresses the blend until `retry_at`, doubling from
|
||||
/// [`BLEND_RETRY_MIN`] to [`BLEND_RETRY_MAX`] while failures continue, and the first success
|
||||
/// clears it.
|
||||
blend_fail: Option<BlendFail>,
|
||||
cursor_blend_failed: bool,
|
||||
/// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources
|
||||
/// keep independent serial namespaces, so once crossed we never go back (see there).
|
||||
cursor_shm_latched: bool,
|
||||
/// Poller heartbeat watch: the last sampled publish count and when it last ADVANCED. A poller
|
||||
/// that is `alive()` but wedged stops advancing it while never exiting — invisible before.
|
||||
cursor_poll_watch: (u64, Instant),
|
||||
/// Whether the wedged-poller warning has already been emitted for the CURRENT stall (cleared
|
||||
/// when it resumes), so a permanently wedged poller warns once rather than every tick.
|
||||
cursor_poll_stalled: bool,
|
||||
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
|
||||
/// it was built for — rebuilt when the ring geometry changes.
|
||||
blend_scratch: Option<(
|
||||
@@ -455,12 +401,10 @@ pub struct IddPushCapturer {
|
||||
u32,
|
||||
DXGI_FORMAT,
|
||||
)>,
|
||||
/// What the LAST blend actually DREW — the composite-regen change key: pointer-only motion
|
||||
/// produces no driver publish (the declared hardware cursor doesn't dirty frames), so
|
||||
/// `try_consume` regenerates from the last slot when this changes. `None` = the frame carries
|
||||
/// no pointer (hidden or no shape yet), which is why a HIDDEN pointer's position is not part
|
||||
/// of the key — see [`Self::cursor_blend_key`].
|
||||
last_blend_key: Option<(u64, i32, i32)>,
|
||||
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
|
||||
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
|
||||
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
|
||||
last_blend_key: Option<(u64, i32, i32, bool)>,
|
||||
/// The ring slot of the last FRESH publish — the regen source.
|
||||
last_slot: Option<usize>,
|
||||
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
|
||||
@@ -603,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
|
||||
@@ -1267,17 +1212,10 @@ impl IddPushCapturer {
|
||||
/// poller meant pointer-less frames, not a degraded pointer.
|
||||
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
if !self.cursor_shm_latched {
|
||||
// Sample the heartbeat and the snapshot together, then drop the borrow so the watch
|
||||
// can take `&mut self`. `alive()` is liveness only — `watch_cursor_publishes` is what
|
||||
// tells a working poller apart from a wedged one.
|
||||
let sampled = self
|
||||
.cursor_poll
|
||||
.as_ref()
|
||||
.filter(|p| p.alive())
|
||||
.map(|p| (p.publishes(), p.read()));
|
||||
if let Some((n, overlay)) = sampled {
|
||||
self.watch_cursor_publishes(n);
|
||||
return overlay;
|
||||
if let Some(p) = &self.cursor_poll {
|
||||
if p.alive() {
|
||||
return p.read();
|
||||
}
|
||||
}
|
||||
// The poller is gone (or never started) and we are about to read the shm — latch, so a
|
||||
// poller that somehow reports alive again cannot re-cross the serial namespaces.
|
||||
@@ -1318,91 +1256,17 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch the GDI poller's heartbeat and log the transitions. The poller is the ONLY
|
||||
/// full-fidelity shape source (the driver's query is alpha-only — `cursor_poll.rs`), so a
|
||||
/// poller that is alive but no longer publishing freezes the pointer in every frame at its
|
||||
/// last sampled shape and position. That state used to be completely silent: `alive()` stays
|
||||
/// true, the slot keeps returning its last snapshot, and nothing in the log distinguishes it
|
||||
/// from a genuinely motionless pointer.
|
||||
fn watch_cursor_publishes(&mut self, n: u64) {
|
||||
let (last, since) = self.cursor_poll_watch;
|
||||
if n != last {
|
||||
self.cursor_poll_watch = (n, Instant::now());
|
||||
if self.cursor_poll_stalled {
|
||||
self.cursor_poll_stalled = false;
|
||||
tracing::info!(
|
||||
target_id = self.target_id,
|
||||
"cursor poller resumed publishing — the pointer tracks again"
|
||||
);
|
||||
}
|
||||
} else if !self.cursor_poll_stalled && since.elapsed() >= POLLER_STALL {
|
||||
self.cursor_poll_stalled = true;
|
||||
tracing::warn!(
|
||||
target_id = self.target_id,
|
||||
stalled_ms = since.elapsed().as_millis() as u64,
|
||||
"cursor poller is ALIVE but has stopped publishing — the pointer is frozen at its \
|
||||
last sampled shape/position (input-desktop reads failing every tick?)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the composite blend currently suppressed by a failure's backoff?
|
||||
fn blend_suppressed(&self) -> bool {
|
||||
self.blend_fail
|
||||
.as_ref()
|
||||
.is_some_and(|f| Instant::now() < f.retry_at)
|
||||
}
|
||||
|
||||
/// Record a composite-blend failure and arm the next retry (see [`BlendFail`]). Logs EVERY
|
||||
/// escalation rather than only the first — a pointer-less capture-model session is a
|
||||
/// user-visible fault, and the old warn-once left a permanently broken one indistinguishable
|
||||
/// in the log from a single transient hiccup at startup.
|
||||
fn note_blend_failure(&mut self, why: &str) {
|
||||
let backoff = next_blend_backoff(self.blend_fail.as_ref().map(|f| f.backoff));
|
||||
let consecutive = self.blend_fail.as_ref().map_or(1, |f| f.consecutive + 1);
|
||||
self.blend_fail = Some(BlendFail {
|
||||
retry_at: Instant::now() + backoff,
|
||||
backoff,
|
||||
consecutive,
|
||||
});
|
||||
tracing::warn!(
|
||||
consecutive,
|
||||
retry_in_ms = backoff.as_millis() as u64,
|
||||
"cursor composite: {why} — frames stay pointer-less until the retry succeeds"
|
||||
);
|
||||
}
|
||||
|
||||
/// A blend succeeded: retire any failure record so the next one starts at the short backoff.
|
||||
fn note_blend_success(&mut self) {
|
||||
if let Some(f) = self.blend_fail.take() {
|
||||
tracing::info!(
|
||||
after_consecutive_failures = f.consecutive,
|
||||
"cursor composite: blend recovered — the pointer is back in frames"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a blend would DRAW this tick — `(serial, x, y)` for a visible pointer, `None` for a
|
||||
/// hidden or not-yet-known one. Keyed on the drawn RESULT rather than on raw cursor state so
|
||||
/// that a HIDDEN pointer moving — routine, because that is exactly what a game that grabbed
|
||||
/// the pointer does — cannot force a frame regeneration on an otherwise idle desktop. The
|
||||
/// visible⇄hidden transitions still change the key (`Some`⇄`None`), so the frame that must
|
||||
/// gain or lose the pointer is still regenerated.
|
||||
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32)> {
|
||||
blend_key_of(self.live_cursor().as_ref())
|
||||
/// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
|
||||
/// `None` while no source has a shape yet.
|
||||
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
|
||||
self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
|
||||
}
|
||||
|
||||
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
|
||||
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
|
||||
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
|
||||
/// to the pointer-less slot, which is the correct frame whenever nothing would be drawn.
|
||||
///
|
||||
/// **There is NO scratch and NO copy when the pointer is hidden or unknown.** The full-frame
|
||||
/// `CopyResource` below is the single largest cost of the composite model — a 4K FP16 ring
|
||||
/// slot is 66 MB, so at 120 fps an unconditional copy is ~8 GB/s of write bandwidth — and it
|
||||
/// buys nothing when the blend that follows draws nothing. A game that grabbed the pointer
|
||||
/// hides it, so this early-out is what makes the capture model free in the state it spends
|
||||
/// most of its life in.
|
||||
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
|
||||
/// blends nothing (the plain copy is the correct frame).
|
||||
///
|
||||
/// # Safety
|
||||
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
|
||||
@@ -1411,18 +1275,6 @@ impl IddPushCapturer {
|
||||
&mut self,
|
||||
slot_tex: &ID3D11Texture2D,
|
||||
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
|
||||
// Resolve WHAT WOULD BE DRAWN first, and record it as the applied key even when that is
|
||||
// "nothing" — `try_consume`'s regen test compares against this, so an early-out must still
|
||||
// leave the key describing the frame we are about to emit. Through `live_cursor`, so a
|
||||
// dead poller degrades to the shm section here too.
|
||||
let overlay = self.live_cursor();
|
||||
self.last_blend_key = blend_key_of(overlay.as_ref());
|
||||
let ov = overlay.filter(|o| o.visible)?;
|
||||
// Blending is suppressed while a recent failure's backoff runs — skip the scratch and the
|
||||
// copy too, not just the draw: with nothing to draw onto it, the copy is pure waste.
|
||||
if self.blend_suppressed() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate
|
||||
// context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView`
|
||||
// take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before
|
||||
@@ -1474,7 +1326,13 @@ impl IddPushCapturer {
|
||||
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
|
||||
}
|
||||
None => {
|
||||
self.note_blend_failure("scratch creation failed");
|
||||
if !self.cursor_blend_failed {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!(
|
||||
"cursor blend scratch creation failed — capture-model frames stay \
|
||||
pointer-less this session"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1482,33 +1340,38 @@ impl IddPushCapturer {
|
||||
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
|
||||
let (tex, srv) = (tex.clone(), srv.clone());
|
||||
self.context.CopyResource(&tex, slot_tex);
|
||||
// Draw `ov` — resolved and keyed at the top, where a hidden pointer already took the
|
||||
// early-out, so reaching here means there IS something to blend.
|
||||
if self.cursor_blend.is_none() {
|
||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
||||
Ok(p) => self.cursor_blend = Some(p),
|
||||
Err(e) => {
|
||||
self.note_blend_failure(&format!("blend pass build failed: {e:#}"));
|
||||
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
|
||||
// Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
|
||||
// is the path that actually draws the pointer in the composite model, and the one that
|
||||
// used to read the poller unconditionally.
|
||||
let overlay = self.live_cursor();
|
||||
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
|
||||
if let Some(ov) = overlay.filter(|o| o.visible) {
|
||||
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
|
||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
||||
Ok(p) => self.cursor_blend = Some(p),
|
||||
Err(e) => {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!(
|
||||
"cursor blend pass build failed — capture-model frames stay \
|
||||
pointer-less this session: {e:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
|
||||
// scale it to the target's SDR white so it matches the desktop around it.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
// One good draw retires the whole failure record: whatever broke has healed,
|
||||
// and the next failure should get the SHORT retry, not the escalated one.
|
||||
Ok(()) => self.note_blend_success(),
|
||||
Err(e) => {
|
||||
// Drop the pass so the block above rebuilds it: a device-loss failure is
|
||||
// transient, but a pass built against the lost device never succeeds again.
|
||||
self.cursor_blend = None;
|
||||
self.note_blend_failure(&format!("blend draw failed: {e:#}"));
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
|
||||
// scale it to the target's SDR white so it matches the desktop around it.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
if !self.cursor_blend_failed {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2213,84 +2076,6 @@ mod tests {
|
||||
use super::stall::Stall;
|
||||
use super::*;
|
||||
|
||||
/// A `CursorOverlay` at `(x, y)` with `serial`, visible or not. `rgba` is never read by the
|
||||
/// key/backoff logic under test, so a 1×1 pixel keeps the fixtures honest about that.
|
||||
fn overlay(serial: u64, x: i32, y: i32, visible: bool) -> pf_frame::CursorOverlay {
|
||||
pf_frame::CursorOverlay {
|
||||
x,
|
||||
y,
|
||||
w: 1,
|
||||
h: 1,
|
||||
rgba: std::sync::Arc::new(vec![0, 0, 0, 0]),
|
||||
serial,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
visible,
|
||||
}
|
||||
}
|
||||
|
||||
/// The regen key is what would be DRAWN, so a hidden pointer keys to `None` no matter where it
|
||||
/// is. This is the whole point: a game that grabbed the pointer moves it constantly, and each
|
||||
/// of those moves used to re-encode the last slot for a frame that is pixel-identical.
|
||||
#[test]
|
||||
fn a_hidden_pointer_has_no_blend_key_wherever_it_moves() {
|
||||
assert_eq!(blend_key_of(None), None, "no overlay ⇒ nothing drawn");
|
||||
assert_eq!(
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
None,
|
||||
"hidden ⇒ nothing drawn"
|
||||
);
|
||||
assert_eq!(
|
||||
blend_key_of(Some(&overlay(7, 999, 999, false))),
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
"a hidden pointer moving must NOT look like a change"
|
||||
);
|
||||
}
|
||||
|
||||
/// …but every transition that alters the drawn frame still changes the key, or the pointer
|
||||
/// would freeze on screen (the failure mode opposite to the one above).
|
||||
#[test]
|
||||
fn every_visible_change_moves_the_blend_key() {
|
||||
let shown = blend_key_of(Some(&overlay(7, 10, 10, true)));
|
||||
assert_eq!(shown, Some((7, 10, 10)));
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(7, 11, 10, true))),
|
||||
"a visible pointer moving is a change"
|
||||
);
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(8, 10, 10, true))),
|
||||
"a new shape at the same spot is a change"
|
||||
);
|
||||
assert_ne!(
|
||||
shown,
|
||||
blend_key_of(Some(&overlay(7, 10, 10, false))),
|
||||
"visible → hidden must regenerate the frame that loses the pointer"
|
||||
);
|
||||
}
|
||||
|
||||
/// The retry escalates and then holds at the ceiling — it must never grow without bound (the
|
||||
/// point of a ceiling is that a device which comes back is picked up within it).
|
||||
#[test]
|
||||
fn the_blend_retry_backoff_doubles_then_caps() {
|
||||
let first = next_blend_backoff(None);
|
||||
assert_eq!(first, BLEND_RETRY_MIN, "the first failure retries quickly");
|
||||
assert_eq!(next_blend_backoff(Some(first)), first * 2, "then doubles");
|
||||
|
||||
// Walk it well past the cap and assert it PARKS there rather than overshooting.
|
||||
let mut b = first;
|
||||
for _ in 0..32 {
|
||||
b = next_blend_backoff(Some(b));
|
||||
}
|
||||
assert_eq!(b, BLEND_RETRY_MAX, "escalation parks at the ceiling");
|
||||
assert_eq!(
|
||||
next_blend_backoff(Some(BLEND_RETRY_MAX)),
|
||||
BLEND_RETRY_MAX,
|
||||
"and stays there"
|
||||
);
|
||||
}
|
||||
|
||||
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
|
||||
///
|
||||
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
|
||||
|
||||
@@ -68,11 +68,6 @@ pub(super) struct CursorPoller {
|
||||
/// while the secure desktop needs the software-cursor path to render (see
|
||||
/// `IddPushCapturer::poll_secure_desktop`).
|
||||
secure: Arc<AtomicBool>,
|
||||
/// Monotonic count of published snapshots — the poller's HEARTBEAT. It advances once per
|
||||
/// successful poll (a failed `GetCursorInfo` `continue`s before the publish), so a thread that
|
||||
/// is wedged on an input desktop it can no longer read stops advancing this while never
|
||||
/// exiting. [`Self::alive`] cannot see that state: it only asks whether the thread finished.
|
||||
ticks: Arc<AtomicU64>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -111,12 +106,10 @@ impl CursorPoller {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let secure = Arc::new(AtomicBool::new(false));
|
||||
let ticks = Arc::new(AtomicU64::new(0));
|
||||
let (slot_t, stop_t, secure_t, ticks_t) =
|
||||
(slot.clone(), stop.clone(), secure.clone(), ticks.clone());
|
||||
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-cursor-poll".into())
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t, &ticks_t))
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
|
||||
.ok();
|
||||
if thread.is_none() {
|
||||
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
|
||||
@@ -125,7 +118,6 @@ impl CursorPoller {
|
||||
slot,
|
||||
stop,
|
||||
secure,
|
||||
ticks,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
@@ -141,14 +133,7 @@ impl CursorPoller {
|
||||
self.secure.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The heartbeat count (see [`Self::ticks`]). Compared against its own previous value by the
|
||||
/// capturer — the ABSOLUTE value means nothing, only whether it is still moving.
|
||||
pub(super) fn publishes(&self) -> u64 {
|
||||
self.ticks.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
|
||||
/// Note this is liveness, NOT health: see [`Self::publishes`].
|
||||
pub(super) fn alive(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| !t.is_finished())
|
||||
}
|
||||
@@ -170,7 +155,6 @@ fn run(
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
ticks: &AtomicU64,
|
||||
) {
|
||||
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
|
||||
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
|
||||
@@ -322,8 +306,6 @@ fn run(
|
||||
}
|
||||
});
|
||||
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
|
||||
// Heartbeat AFTER the publish, so it counts snapshots the capturer can actually read.
|
||||
ticks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -656,9 +656,7 @@ impl IddPushCapturer {
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
blend_fail: None,
|
||||
cursor_poll_watch: (0, std::time::Instant::now()),
|
||||
cursor_poll_stalled: false,
|
||||
cursor_blend_failed: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
|
||||
@@ -134,6 +134,12 @@ pf-vaadec = { path = "../pf-vaadec" }
|
||||
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
|
||||
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
|
||||
libloading = "0.8"
|
||||
# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a
|
||||
# gamescope root window and block on PropertyNotify. `default-features = false` keeps the
|
||||
# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package
|
||||
# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension
|
||||
# features: root-window properties and an event mask are core X11.
|
||||
x11rb = { version = "0.13", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
|
||||
@@ -381,6 +381,7 @@ enum Ctl {
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
Mask(bool),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -548,6 +549,31 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
/// A system overlay owns the controller right now — hold every forwarded pad NEUTRAL
|
||||
/// until it closes. This is the Steam Input behaviour a streaming client has to
|
||||
/// reproduce by hand: while the Deck's Steam menu or QAM is up, the same physical
|
||||
/// sticks and buttons drive Steam's UI, and anything we keep forwarding lands in the
|
||||
/// game underneath as a second, invisible player.
|
||||
///
|
||||
/// **Masking is not [`set_forwarding`](Self::set_forwarding).** Forwarding-off closes the
|
||||
/// slot and sends the host a [`GamepadRemove`](InputKind::GamepadRemove) — the game sees a
|
||||
/// controller *unplug*, which is a hardware event with real in-game consequences (pause
|
||||
/// menus, "reconnect your controller", player-slot churn). Opening the QAM must not look
|
||||
/// like that. Masking keeps every slot open and merely stops the transitions, after
|
||||
/// flushing what the host believes is held so a stick held at overlay-open stops steering
|
||||
/// instead of freezing at its last value.
|
||||
///
|
||||
/// SDL has this gate of its own — it drops presses while the process has windows but no
|
||||
/// keyboard focus — and on a desktop it fires. It CANNOT fire on a Deck in Gaming Mode:
|
||||
/// gamescope resolves focus per Xwayland ctx, and the client sits alone in its own ctx, so
|
||||
/// its X input focus never moves when the overlay takes over (measured). That is why this
|
||||
/// exists as an explicit lever rather than something inherited for free.
|
||||
///
|
||||
/// Held state is adopted, not replayed, on the way back — see [`Ctl::Mask`]'s handling.
|
||||
pub fn set_masked(&self, on: bool) {
|
||||
let _ = self.ctl.send(Ctl::Mask(on));
|
||||
}
|
||||
|
||||
/// The session's system-button policy, resolved from
|
||||
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
|
||||
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
|
||||
@@ -1069,6 +1095,9 @@ struct Worker {
|
||||
menu_mode: bool,
|
||||
menu_nav: MenuNav,
|
||||
menu_tx: async_channel::Sender<MenuEvent>,
|
||||
/// A system overlay owns input ([`GamepadService::set_masked`]): forwarded pads are held
|
||||
/// neutral and menu translation is paused, with every slot still OPEN.
|
||||
masked: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -1519,6 +1548,87 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-adopt what the pads are physically holding when an overlay mask lifts.
|
||||
///
|
||||
/// Buttons are taken back into `held_buttons` **without** a wire press: a button pressed
|
||||
/// inside the overlay (the A that picked a QAM row) must not fire in the game the instant it
|
||||
/// closes — releasing it and pressing again is what arms it. Same rule menu mode already
|
||||
/// applies across a screen handoff ([`MenuNav::reset`]), for the same reason.
|
||||
///
|
||||
/// Axes ARE re-sent, because a stick has no press semantics to ghost — it is deflected or it
|
||||
/// is not. The mask flushed them to zero, and SDL only speaks on *change*, so a stick still
|
||||
/// held when the overlay closes would stay dead host-side until the user happened to move it.
|
||||
///
|
||||
/// Neither half can run against a pad that is gone: this only walks open slots, and every SDL
|
||||
/// read here is a state query on a handle the slot owns.
|
||||
fn readopt_held(&mut self) {
|
||||
use sdl3::gamepad::{Axis, Button};
|
||||
// Every button `button_bit` maps — the same surface the press path forwards.
|
||||
const BUTTONS: [Button; 21] = [
|
||||
Button::South,
|
||||
Button::East,
|
||||
Button::West,
|
||||
Button::North,
|
||||
Button::Back,
|
||||
Button::Start,
|
||||
Button::Guide,
|
||||
Button::LeftStick,
|
||||
Button::RightStick,
|
||||
Button::LeftShoulder,
|
||||
Button::RightShoulder,
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
Button::Touchpad,
|
||||
Button::RightPaddle1,
|
||||
Button::LeftPaddle1,
|
||||
Button::RightPaddle2,
|
||||
Button::LeftPaddle2,
|
||||
Button::Misc1,
|
||||
];
|
||||
const AXES: [Axis; 6] = [
|
||||
Axis::LeftX,
|
||||
Axis::LeftY,
|
||||
Axis::RightX,
|
||||
Axis::RightY,
|
||||
Axis::TriggerLeft,
|
||||
Axis::TriggerRight,
|
||||
];
|
||||
// Copied out: the slot walk below borrows `self` mutably.
|
||||
let system_forward = self.system_forward;
|
||||
let attached = self.attached.clone();
|
||||
for slot in &mut self.slots {
|
||||
slot.held_buttons.clear();
|
||||
for b in BUTTONS {
|
||||
let Some(bit) = button_bit(b) else {
|
||||
continue;
|
||||
};
|
||||
// The press path returns before `held_buttons` for un-forwarded system
|
||||
// buttons; tracking them here would invent state it never keeps.
|
||||
if !system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
|
||||
continue;
|
||||
}
|
||||
if slot.pad.button(b) {
|
||||
slot.held_buttons.push(bit);
|
||||
}
|
||||
}
|
||||
let Some(c) = &attached else {
|
||||
continue;
|
||||
};
|
||||
for a in AXES {
|
||||
let (id, v) = axis_value(a, slot.pad.axis(a));
|
||||
if slot.last_axis[id as usize] != v {
|
||||
slot.last_axis[id as usize] = v;
|
||||
send(c, InputKind::GamepadAxis, id, v, slot.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The chord latch was cleared on the way in; drop it again if what we just adopted
|
||||
// doesn't actually hold it.
|
||||
self.rearm_escape();
|
||||
}
|
||||
|
||||
/// True when any one forwarded pad holds the entire escape chord (any player can leave).
|
||||
fn chord_held(&self) -> bool {
|
||||
self.slots
|
||||
@@ -1785,6 +1895,34 @@ impl Worker {
|
||||
.push((pad, bit, Instant::now() + TAP_PRESS));
|
||||
}
|
||||
}
|
||||
Ok(Ctl::Mask(on)) => {
|
||||
if self.masked == on {
|
||||
continue;
|
||||
}
|
||||
self.masked = on;
|
||||
if on {
|
||||
// Neutral NOW, and while the slots stay open: a stick held when the
|
||||
// overlay opened must stop steering, but the host must not see the pad
|
||||
// unplug (that is `close_slot_at`'s job, and a game reacts to it).
|
||||
if let Some(c) = self.attached.clone() {
|
||||
for slot in &mut self.slots {
|
||||
Self::flush_slot(&c, slot);
|
||||
}
|
||||
}
|
||||
// Nothing can be mid-chord across the flip: the transitions that would
|
||||
// complete or break it are about to be dropped.
|
||||
self.reset_chord();
|
||||
} else {
|
||||
// Coming back. Whatever is still physically held was never delivered —
|
||||
// adopt it silently rather than replay it as a fresh press, the same
|
||||
// rule menu mode uses across a screen handoff (`MenuNav::reset`). A
|
||||
// button you pressed *inside* the overlay must not fire in the game the
|
||||
// instant it closes; releasing and pressing again is what arms it.
|
||||
self.readopt_held();
|
||||
self.menu_nav.reset();
|
||||
}
|
||||
tracing::info!(masked = on, "overlay input mask");
|
||||
}
|
||||
Ok(Ctl::Forwarding(on)) => {
|
||||
if self.forwarding == on {
|
||||
continue;
|
||||
@@ -1846,6 +1984,28 @@ impl Worker {
|
||||
/// "is a session live".
|
||||
fn handle_event(&mut self, event: sdl3::event::Event) {
|
||||
use sdl3::event::Event;
|
||||
// A system overlay owns the controller ([`GamepadService::set_masked`]): drop every
|
||||
// input transition. The pads were flushed neutral when the mask went on, so dropping
|
||||
// the ups as well as the downs is what keeps the two in agreement — `readopt_held`
|
||||
// rebuilds the held set from the hardware when it lifts.
|
||||
//
|
||||
// Device add/remove deliberately still count: a controller genuinely plugged in or
|
||||
// pulled out behind an overlay is a fact about the world, not an input, and losing it
|
||||
// would leave the slot table lying about what exists.
|
||||
if self.masked
|
||||
&& matches!(
|
||||
event,
|
||||
Event::ControllerButtonDown { .. }
|
||||
| Event::ControllerButtonUp { .. }
|
||||
| Event::ControllerAxisMotion { .. }
|
||||
| Event::ControllerTouchpadDown { .. }
|
||||
| Event::ControllerTouchpadMotion { .. }
|
||||
| Event::ControllerTouchpadUp { .. }
|
||||
| Event::ControllerSensorUpdated { .. }
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !self.order.contains(&which) {
|
||||
@@ -2074,7 +2234,9 @@ impl Worker {
|
||||
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
|
||||
/// so a press is translated the iteration it arrives).
|
||||
fn menu_poll(&mut self) {
|
||||
if !self.menu_mode || self.attached.is_some() {
|
||||
// Masked covers the launcher too: with the Deck's Steam menu up over our console, the
|
||||
// same stick that scrolls Steam's UI would otherwise also be scrolling ours behind it.
|
||||
if !self.menu_mode || self.attached.is_some() || self.masked {
|
||||
return;
|
||||
}
|
||||
let Some((_, pad)) = self.menu_open.as_ref() else {
|
||||
@@ -2301,6 +2463,7 @@ impl Worker {
|
||||
menu_mode: false,
|
||||
menu_nav: MenuNav::new(),
|
||||
menu_tx,
|
||||
masked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ pub mod orchestrate;
|
||||
// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string
|
||||
// logic, built everywhere (the Apple/Android ports mirror it rather than link it).
|
||||
pub mod os;
|
||||
// "A system overlay owns the controller" for gamescope Gaming Mode — the signal behind the
|
||||
// gamepad input mask, which SDL's own focus gate structurally cannot provide there.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod overlay_focus;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! "A system overlay owns the controller right now" — the gamescope half of the input mask.
|
||||
//!
|
||||
//! On a Steam Deck in Gaming Mode the Steam menu and the QAM are drawn by Steam and driven by
|
||||
//! the *same physical controller* the client is forwarding. Steam does not mask us the way it
|
||||
//! masks a normal game: masking happens on Steam Input's virtual pad, and the client
|
||||
//! deliberately forwards the REAL pad instead (28DE:1205 — the virtual one has no gyro,
|
||||
//! trackpads or paddles). So while the QAM is up, one thumbstick drives Steam's UI *and* the
|
||||
//! game on the host. This watcher is what tells [`crate::gamepad::GamepadService::set_masked`]
|
||||
//! to stop that.
|
||||
//!
|
||||
//! **Why the free mechanism can't do it.** SDL already drops gamepad presses while the process
|
||||
//! has windows but no keyboard focus (`SDL_PrivateJoystickShouldIgnoreEvent`, on by default —
|
||||
//! we never set `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS`), and on a desktop that fires. It cannot
|
||||
//! fire here: gamescope resolves focus **per Xwayland ctx** (`determine_and_apply_focus` scans
|
||||
//! only that ctx's window list), the Steam overlay lives in the root ctx, and the client sits
|
||||
//! alone in its own. Measured on a Deck 2026-08-08: with the QAM open, X input focus inside the
|
||||
//! client's ctx never moved off its window, so no `FocusOut` is ever generated. Hence an
|
||||
//! explicit signal.
|
||||
//!
|
||||
//! **The signal.** gamescope publishes two CARDINALs on the ROOT ctx's root window (Steam mode
|
||||
//! only, i.e. `gamescope -e` — which is what Gaming Mode runs):
|
||||
//!
|
||||
//! * `GAMESCOPE_FOCUSED_APP` — appid of the window holding **input** focus
|
||||
//! * `GAMESCOPE_FOCUSED_APP_GFX` — appid of the window being **displayed**
|
||||
//!
|
||||
//! They are equal in normal play and diverge exactly while something else has taken input over
|
||||
//! the running app. Measured, both for the Steam menu and for the QAM:
|
||||
//!
|
||||
//! ```text
|
||||
//! app=3856846079 gfx=3856846079 ← streaming, we own input
|
||||
//! app=769 gfx=3856846079 ← overlay open (769 = Steam)
|
||||
//! ```
|
||||
//!
|
||||
//! Note `app != gfx` rather than "app is Steam": anything that takes input away from the
|
||||
//! displayed app is a thing we should stop forwarding through, and comparing to our own appid
|
||||
//! would need us to know it (a non-Steam shortcut's appid is assigned by Steam at creation).
|
||||
//!
|
||||
//! **Which display.** Not necessarily ours. Gaming Mode runs `gamescope --xwayland-count 2`:
|
||||
//! Steam and the atoms live on the first server, the app is given the second, and the client's
|
||||
//! own `$DISPLAY` therefore has none of these properties. So discovery walks candidates — our
|
||||
//! `$DISPLAY` first (correct for a single-server gamescope), then every socket in
|
||||
//! `/tmp/.X11-unix` — and keeps the first whose root actually carries both atoms. gamescope's
|
||||
//! Xwayland accepts unauthenticated local connections (verified: `xprop` against it succeeds
|
||||
//! with no `.Xauthority` at all), so no cookie plumbing is needed.
|
||||
//!
|
||||
//! Everything here is best-effort by construction: no gamescope, no X, a sandbox that cannot
|
||||
//! see the other socket, or a session that restarts underneath us all end in "no signal", which
|
||||
//! degrades to exactly the behaviour that shipped before this module existed.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{
|
||||
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt, EventMask, Window,
|
||||
};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long to wait before rebuilding everything after the X connection drops. Gaming Mode
|
||||
/// recreates its Xwayland servers across a session restart, so "gone" is not permanent — but it
|
||||
/// is also not worth a hot retry loop.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Live "an overlay owns input" flag, updated by a background thread.
|
||||
///
|
||||
/// Cheap to poll (one relaxed atomic load), which is what the presenter's event loop wants — it
|
||||
/// checks once per iteration and only talks to the gamepad service on an edge.
|
||||
pub struct OverlayFocus {
|
||||
open: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl OverlayFocus {
|
||||
/// Start watching, or return `None` when this isn't a gamescope Steam session (the common
|
||||
/// case — every desktop client) or the user opted out with `PUNKTFUNK_OVERLAY_MASK=0`.
|
||||
///
|
||||
/// Returning `None` is not a failure: the caller keeps its window-focus path, which is the
|
||||
/// right signal everywhere the compositor actually moves focus.
|
||||
pub fn start() -> Option<OverlayFocus> {
|
||||
if std::env::var("PUNKTFUNK_OVERLAY_MASK").is_ok_and(|v| v == "0" || v == "false") {
|
||||
tracing::info!("overlay input mask disabled by PUNKTFUNK_OVERLAY_MASK");
|
||||
return None;
|
||||
}
|
||||
if !gamescope_session() {
|
||||
return None;
|
||||
}
|
||||
let open = Arc::new(AtomicBool::new(false));
|
||||
let flag = open.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-overlay-focus".into())
|
||||
.spawn(move || watch(&flag))
|
||||
.map_err(|e| tracing::warn!(error = %e, "overlay focus watcher failed to start"))
|
||||
.ok()?;
|
||||
Some(OverlayFocus { open })
|
||||
}
|
||||
|
||||
/// Does something other than the displayed app own input right now?
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaming Mode / any gamescope session — the only place this signal exists. Mirrors the same
|
||||
/// env checks the shells already use to detect Gaming Mode.
|
||||
fn gamescope_session() -> bool {
|
||||
std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|
||||
}
|
||||
|
||||
/// Displays worth trying, in order: ours first (a single-server gamescope publishes the atoms on
|
||||
/// the display the app is already on), then every other socket present. `/tmp/.X11-unix` is
|
||||
/// listed rather than probing `:0..:N` blindly so we never connect to a display that isn't there.
|
||||
fn candidate_displays() -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(d) = std::env::var("DISPLAY") {
|
||||
if !d.is_empty() {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir("/tmp/.X11-unix") {
|
||||
let mut found: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().into_string().ok()?;
|
||||
let n = name.strip_prefix('X')?;
|
||||
n.parse::<u32>().ok().map(|n| format!(":{n}"))
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
for d in found {
|
||||
if !out.contains(&d) {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The two atoms on a root that carries them, or `None` for a display that isn't gamescope's
|
||||
/// root ctx. `only_if_exists` keeps this from interning atoms into unrelated X servers.
|
||||
fn gamescope_atoms(conn: &RustConnection) -> Option<(Atom, Atom)> {
|
||||
let app = conn
|
||||
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP")
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?
|
||||
.atom;
|
||||
let gfx = conn
|
||||
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP_GFX")
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?
|
||||
.atom;
|
||||
(app != 0 && gfx != 0).then_some((app, gfx))
|
||||
}
|
||||
|
||||
/// Read one CARDINAL appid. gamescope writes these with a length of ZERO when the appid is 0
|
||||
/// (`focusedAppId != 0 ? 1 : 0`), so "present but empty" is a real state meaning "no app" — it
|
||||
/// must read as `None`, not as `Some(0)` that would then compare unequal to everything.
|
||||
fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
|
||||
let reply = conn
|
||||
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?;
|
||||
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
|
||||
// expression its temporary would outlive it.
|
||||
let id = reply.value32()?.next();
|
||||
id
|
||||
}
|
||||
|
||||
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
|
||||
/// input focus and the displayed app are both known and DIFFER.
|
||||
///
|
||||
/// Absence is never an overlay. A missing value means "no app focused" (gamescope's zero-length
|
||||
/// write) or "this display stopped answering" — and a mask that latched on when the signal went
|
||||
/// away would silently kill the controller for the rest of the session, which is a far worse
|
||||
/// failure than not masking at all.
|
||||
fn overlay_open_from(app: Option<u32>, gfx: Option<u32>) -> bool {
|
||||
matches!((app, gfx), (Some(a), Some(g)) if a != g)
|
||||
}
|
||||
|
||||
/// True when input focus and the displayed app have diverged — an overlay is up.
|
||||
fn overlay_open(conn: &RustConnection, root: Window, app: Atom, gfx: Atom) -> bool {
|
||||
overlay_open_from(read_appid(conn, root, app), read_appid(conn, root, gfx))
|
||||
}
|
||||
|
||||
/// Connect, find the root ctx, then block on PropertyNotify for the two atoms. Returns on any X
|
||||
/// error so the outer loop can rebuild after a session restart.
|
||||
fn watch(flag: &Arc<AtomicBool>) {
|
||||
loop {
|
||||
if let Some((conn, root, app, gfx)) = connect() {
|
||||
// Seed before the first event: the overlay may already be up when we start.
|
||||
flag.store(overlay_open(&conn, root, app, gfx), Ordering::Relaxed);
|
||||
loop {
|
||||
match conn.wait_for_event() {
|
||||
Ok(Event::PropertyNotify(e)) if e.atom == app || e.atom == gfx => {
|
||||
let open = overlay_open(&conn, root, app, gfx);
|
||||
if flag.swap(open, Ordering::Relaxed) != open {
|
||||
tracing::debug!(open, "gamescope overlay focus changed");
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::info!(error = %e, "gamescope focus watcher disconnected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dropped connection tells us nothing about the controller — unmask, or a
|
||||
// gamescope restart mid-overlay would leave the pad dead with nothing to revive it.
|
||||
flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
std::thread::sleep(RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first candidate display whose root carries both atoms, with PropertyNotify selected.
|
||||
fn connect() -> Option<(RustConnection, Window, Atom, Atom)> {
|
||||
for dpy in candidate_displays() {
|
||||
// `dpy`, not `display`: `display` is one of tracing's own value helpers, and a field
|
||||
// named after it resolves to the helper inside the macro rather than to this string.
|
||||
let Ok((conn, screen_num)) = RustConnection::connect(Some(&dpy)) else {
|
||||
continue;
|
||||
};
|
||||
let Some((app, gfx)) = gamescope_atoms(&conn) else {
|
||||
continue;
|
||||
};
|
||||
let root = conn.setup().roots[screen_num].root;
|
||||
// Both atoms must actually be PRESENT on this root, not merely interned: a second
|
||||
// gamescope Xwayland knows the atom names (they are per-server strings) but only the
|
||||
// root ctx publishes the values.
|
||||
if read_appid(&conn, root, gfx).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Checked rather than fire-and-forget: an event mask that silently failed to apply
|
||||
// would leave the watcher blocked forever on a display that never speaks to it.
|
||||
let selected = match conn.change_window_attributes(
|
||||
root,
|
||||
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
||||
) {
|
||||
Ok(cookie) => cookie.check().is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !selected {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(dpy, "watching gamescope focus for overlay input masking");
|
||||
return Some((conn, root, app, gfx));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The measured Deck states, both directions (2026-08-08, Steam menu and QAM alike):
|
||||
/// equal appids while we own input, divergent while the overlay does.
|
||||
#[test]
|
||||
fn divergent_appids_are_an_overlay() {
|
||||
assert!(!overlay_open_from(Some(3856846079), Some(3856846079)));
|
||||
assert!(overlay_open_from(Some(769), Some(3856846079)));
|
||||
}
|
||||
|
||||
/// gamescope writes these properties with a length of ZERO when the appid is 0, so "no app"
|
||||
/// arrives as a missing value rather than `Some(0)`. Reading it as `Some(0)` would make it
|
||||
/// differ from every real appid and mask the pad on an empty Gaming Mode home screen.
|
||||
#[test]
|
||||
fn a_missing_appid_is_never_an_overlay() {
|
||||
assert!(!overlay_open_from(None, Some(3856846079)));
|
||||
assert!(!overlay_open_from(Some(769), None));
|
||||
assert!(!overlay_open_from(None, None));
|
||||
}
|
||||
|
||||
/// The safety property that outranks the feature: if the signal is unreadable we forward as
|
||||
/// before. A latched mask would leave a streaming session with a dead controller and no way
|
||||
/// back short of restarting it.
|
||||
#[test]
|
||||
fn absence_fails_open_not_closed() {
|
||||
assert!(!overlay_open_from(None, None));
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1531,85 @@ pub fn av1_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
d3d11
|
||||
}
|
||||
|
||||
/// Can this client actually DECODE 4:4:4 HEVC — the question `VIDEO_CAP_444` is a promise
|
||||
/// about, and the one nothing asked until a Steam Deck lost HEVC over it.
|
||||
///
|
||||
/// The bit used to ride the "Full chroma" toggle alone, with a comment saying the software
|
||||
/// rung was the floor underneath it. M8 removed that floor: there is no CPU HEVC decoder at
|
||||
/// all ([`software_decodable_codecs`]), and the host grants 4:4:4 only on HEVC. So on a
|
||||
/// device with no 4:4:4 decode the toggle did not cost crispness — it cost the whole codec.
|
||||
/// The Welcome resolves the chroma before a decoder exists, the native Vulkan constructor
|
||||
/// then refuses the shape, VAAPI refuses it too, and the session reconnects on H.264 with
|
||||
/// "HEVC decoding failed on this device" (field report 2026-08-08, Deck / VanGogh).
|
||||
///
|
||||
/// ⭐ Answered from the VULKAN rung alone, and that is exact rather than approximate: it is
|
||||
/// the only rung in this build that implements 4:4:4 at all. `pf_vaadec::profile_for` maps
|
||||
/// only `chroma_format_idc == 1` and errors `UnsupportedShape` on 3; `pf_dxvadec`'s config
|
||||
/// refuses "anything but 4:2:0" by construction; the CPU rung is 8-bit 4:2:0 only. So a
|
||||
/// device whose Vulkan driver offers no 4:4:4 decode profile has no 4:4:4 path in this
|
||||
/// client, whatever its silicon can do. (That is why an Intel box — whose hardware HAS done
|
||||
/// HEVC 4:4:4 since Ice Lake — is still a `false` here: our DXVA/VAAPI rungs do not
|
||||
/// implement it, so advertising it would be a lie about US, not about the GPU.)
|
||||
///
|
||||
/// ⚠ Both depths are required, not either: with HDR on, the host may resolve 4:4:4 **10-bit**,
|
||||
/// and a device offering `YUV444_8` but not `YUV444_10` would land in exactly the hole this
|
||||
/// closes. Asking for both costs one extra capability query and removes the case entirely.
|
||||
///
|
||||
/// ⚠ Deliberately NOT extended to `VIDEO_CAP_10BIT`/`VIDEO_CAP_HDR`, which are advertised
|
||||
/// unprobed for the same reason this one was. The asymmetry is real: all three hardware
|
||||
/// rungs implement 10-bit 4:2:0 (`profile_for` maps `(H265, 1, 10)` and `(Av1, 1, 10)`;
|
||||
/// pf-dxvadec carries P010), so a Vulkan-only probe there would answer `false` on boxes
|
||||
/// whose VAAPI/DXVA rung decodes 10-bit perfectly and would silently withdraw HDR from
|
||||
/// them — a visible regression bought against a case that has never been observed. Gating
|
||||
/// 10-bit honestly needs a libva/D3D11 probe, which this path cannot afford (same reason
|
||||
/// [`av1_hardware_decodable`] does not consult VAAPI).
|
||||
pub fn hevc_444_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
vk.is_some_and(|v| {
|
||||
crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 0)
|
||||
&& crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 2)
|
||||
})
|
||||
}
|
||||
// No native Vulkan rung is compiled in off the two desktop OSes, so nothing here can
|
||||
// decode 4:4:4 and the honest answer is a constant.
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
let _ = vk;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `chroma_format_idc` for 4:4:4 (H.265 7.4.3.2) — spelled once so the two depth probes
|
||||
/// above and any future caller cannot disagree about the magic number.
|
||||
const CHROMA_444: u8 = 3;
|
||||
|
||||
/// The desktop session's `video_caps` bitfield, as a pure function of the two user
|
||||
/// switches that move it — so the rule can be tested without a GPU, a host or a Hello.
|
||||
///
|
||||
/// `want_444` is the "Full chroma" setting **already ANDed with this device's ability to
|
||||
/// decode it** ([`hevc_444_hardware_decodable`]). Split that way on purpose: the caller
|
||||
/// owns the expensive driver question and can log its own refusal with the user's setting
|
||||
/// in hand, while the bit arithmetic — the part that was wrong — stays testable.
|
||||
///
|
||||
/// `MULTI_SLICE` is unconditional and is decoder truth for THIS embedder: every desktop
|
||||
/// decode stack (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying
|
||||
/// several slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||
/// ⚠ The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges on
|
||||
/// multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||
///
|
||||
/// HDR off means 10-bit is not advertised either, so the host never upgrades depth.
|
||||
pub fn video_caps_for(hdr_enabled: bool, want_444: bool) -> u8 {
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE;
|
||||
if hdr_enabled {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
|
||||
}
|
||||
if want_444 {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||
}
|
||||
caps
|
||||
}
|
||||
|
||||
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
||||
/// compute-feature probe, minus the codecs `decoder_pref` makes unreachable.
|
||||
/// Advertisement-only: `resolve_codec` never auto-picks PyroWave — the session must also
|
||||
@@ -2701,6 +2780,53 @@ mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
||||
|
||||
/// The 4:4:4 advertisement is a PROMISE, and M8 removed the floor that used to make a
|
||||
/// broken one survivable: there is no CPU HEVC decoder, and the host grants 4:4:4 on
|
||||
/// HEVC only, so advertising it on a device that cannot decode it costs the entire
|
||||
/// codec (field 2026-08-08, Steam Deck / VanGogh — HEVC fell back to H.264).
|
||||
///
|
||||
/// The device question needs a GPU; THIS is the half that does not, and it is the half
|
||||
/// that was wrong — the bit used to ride `enable_444` alone.
|
||||
#[test]
|
||||
fn the_444_bit_needs_the_setting_and_a_device_that_can_decode_it() {
|
||||
const V444: u8 = punktfunk_core::quic::VIDEO_CAP_444;
|
||||
// The regression itself: setting on, device can't → the bit must NOT go out.
|
||||
assert_eq!(
|
||||
video_caps_for(true, false) & V444,
|
||||
0,
|
||||
"a 4:4:4 promise this device cannot keep costs HEVC entirely"
|
||||
);
|
||||
// ...and the feature still works where it can be honoured.
|
||||
assert_ne!(video_caps_for(true, true) & V444, 0);
|
||||
// Never advertised unasked, whatever the device can do.
|
||||
assert_eq!(video_caps_for(true, false) & V444, 0);
|
||||
assert_eq!(video_caps_for(false, false) & V444, 0);
|
||||
|
||||
// The 4:4:4 gate must not disturb the other two bits (10-bit/HDR is deliberately
|
||||
// NOT probe-gated — see `hevc_444_hardware_decodable`'s docs for why).
|
||||
const HDR_BITS: u8 =
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
|
||||
for want_444 in [false, true] {
|
||||
assert_eq!(video_caps_for(true, want_444) & HDR_BITS, HDR_BITS);
|
||||
assert_eq!(video_caps_for(false, want_444) & HDR_BITS, 0);
|
||||
assert_ne!(
|
||||
video_caps_for(false, want_444) & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
|
||||
0,
|
||||
"MULTI_SLICE is unconditional for this embedder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// No presenter Vulkan device ⇒ no 4:4:4, and that is an ANSWER rather than a missing
|
||||
/// one: the native Vulkan rung is the only one in this build that implements 4:4:4 at
|
||||
/// all (`pf_vaadec::profile_for` errors on `chroma_format_idc == 3`, pf-dxvadec refuses
|
||||
/// anything but 4:2:0, the CPU rung is 8-bit 4:2:0). The `Some` arm needs real hardware
|
||||
/// and lives in the GPU suites.
|
||||
#[test]
|
||||
fn no_vulkan_device_means_no_444_promise() {
|
||||
assert!(!hevc_444_hardware_decodable(None));
|
||||
}
|
||||
|
||||
/// The reconnect rule, as the invariant it is: an exhausted codec must come back as
|
||||
/// one this client can decode ALL THE WAY DOWN, and must never come back as itself.
|
||||
///
|
||||
|
||||
@@ -216,6 +216,70 @@ fn submit_queues_collide(graphics_qf: u32, decode_qf: u32) -> bool {
|
||||
graphics_qf == decode_qf
|
||||
}
|
||||
|
||||
/// The queue lock this device's decode lane submits under. One function so the
|
||||
/// pre-session shape probe ([`hevc_shape_supported`]) and the real decoder cannot pick
|
||||
/// different serialization for the same device.
|
||||
fn queue_lock_for(vk: &VulkanDecodeDevice) -> Box<dyn pf_vkdecode::QueueLock> {
|
||||
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
|
||||
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
|
||||
} else {
|
||||
Box::new(NativeQueueLock::Uncontended)
|
||||
}
|
||||
}
|
||||
|
||||
/// The presenter's handles in pf-vkdecode's shape. Same reason as [`queue_lock_for`]:
|
||||
/// the probe must ask about the DEVICE THE SESSION WOULD USE, not a re-derived one.
|
||||
fn device_handles(vk: &VulkanDecodeDevice) -> DeviceHandles {
|
||||
DeviceHandles {
|
||||
get_instance_proc_addr: vk.get_instance_proc_addr,
|
||||
instance: vk.instance,
|
||||
physical_device: vk.physical_device,
|
||||
device: vk.device,
|
||||
decode_qf: vk.decode_qf,
|
||||
decode_queue_index: DECODE_QUEUE_INDEX,
|
||||
graphics_qf: vk.graphics_qf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this device hardware-decode HEVC at the given picture shape? Asked BEFORE the
|
||||
/// Hello, so the client never advertises a shape it would have to refuse a session over.
|
||||
///
|
||||
/// This is the same question, through the same code, that
|
||||
/// [`NativeVulkanDecoder::new`]'s H.265 arm asks at construction — `VkH265Decoder::new`
|
||||
/// then `probe_stream_support` — deliberately, so an advertisement and the rung that has
|
||||
/// to honour it cannot disagree. It creates and drops a decoder object; that costs a
|
||||
/// handful of driver capability queries and no session, no images and no submits.
|
||||
///
|
||||
/// `false` when the presenter has no Vulkan Video decode at all, which for 4:4:4 is the
|
||||
/// right answer rather than a missing one — see
|
||||
/// [`crate::video::hevc_444_hardware_decodable`] for why no other rung can be asked.
|
||||
pub(crate) fn hevc_shape_supported(
|
||||
vk: &VulkanDecodeDevice,
|
||||
chroma_format_idc: u8,
|
||||
bit_depth_luma_minus8: u8,
|
||||
) -> bool {
|
||||
if !vk.video_decode {
|
||||
return false;
|
||||
}
|
||||
// The device-independent half first: a shape pf-vkdecode has no picture format for
|
||||
// needs no driver to refuse it (and `probe_stream_support` would only re-derive it).
|
||||
if pf_vkdecode::output_format_for(chroma_format_idc, bit_depth_luma_minus8).is_none() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the `DeviceHandles` contract exactly as `NativeVulkanDecoder::new` states
|
||||
// it — these are the presenter's live instance/device, which outlive this call by
|
||||
// construction (the presenter owns them for the whole process, and this runs on its
|
||||
// thread while building the session's Hello). The decoder is dropped before return,
|
||||
// so nothing outlives the borrow.
|
||||
let dec = unsafe { pf_vkdecode::VkH265Decoder::new(&device_handles(vk), queue_lock_for(vk)) };
|
||||
match dec {
|
||||
Ok(d) => d
|
||||
.probe_stream_support(chroma_format_idc, bit_depth_luma_minus8)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`pf_vkdecode::QueueLock`] over the device's shared [`crate::video::QueueLock`] —
|
||||
/// or over nothing, when the decode queue provably has no other submitter (see the
|
||||
/// module doc's queue-lock section).
|
||||
@@ -934,21 +998,8 @@ impl NativeVulkanDecoder {
|
||||
if !vk.video_decode {
|
||||
bail!("presenter device lacks Vulkan Video decode");
|
||||
}
|
||||
let lock: Box<dyn pf_vkdecode::QueueLock> =
|
||||
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
|
||||
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
|
||||
} else {
|
||||
Box::new(NativeQueueLock::Uncontended)
|
||||
};
|
||||
let handles = DeviceHandles {
|
||||
get_instance_proc_addr: vk.get_instance_proc_addr,
|
||||
instance: vk.instance,
|
||||
physical_device: vk.physical_device,
|
||||
device: vk.device,
|
||||
decode_qf: vk.decode_qf,
|
||||
decode_queue_index: DECODE_QUEUE_INDEX,
|
||||
graphics_qf: vk.graphics_qf,
|
||||
};
|
||||
let lock = queue_lock_for(vk);
|
||||
let handles = device_handles(vk);
|
||||
// The `DeviceHandles` caller contract, held for the decoder's whole lifetime
|
||||
// and identical for both arms (it is the HANDLES' contract, not the codec's):
|
||||
// the handles are the presenter's live instance/device, which outlives every
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,32 @@ pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_p
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the 3-bit wire sequence counter out of a pyrowave block header.
|
||||
///
|
||||
/// Every block header is `{ u16 ballot; u16 payload_words:12, sequence:3, extended:1; u32 ... }`
|
||||
/// (`pyrowave_common.hpp`, `static_assert(sizeof == 8)`), so the counter is bits 12..14 of the
|
||||
/// little-endian half-word at `packet_offset + 2` — the same word `stamp_color_bits` reaches into
|
||||
/// from the other end.
|
||||
///
|
||||
/// This field is the entire frame-boundary signal on the wire: the decoder restarts a frame only
|
||||
/// when the value CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a
|
||||
/// repeated value is read as more blocks of the same frame. That is why PW5's alternating encoder
|
||||
/// handles need `pyrowave_encoder_set_next_sequence`, and why a test asserts this reader sees
|
||||
/// +1 mod 8 across the pair.
|
||||
///
|
||||
/// Its only caller is the Linux backend — alternating encoder handles are a Linux-side concern, and
|
||||
/// the Windows backend drives pyrowave's compat device with a single handle. The rest of this module
|
||||
/// really is shared (`packet_boundary` and `stamp_color_bits` have callers on both), so the exemption
|
||||
/// is scoped to this one item rather than the file: `dead_code` stays live on Linux, where the caller
|
||||
/// lives and where its disappearing would be a real finding. Windows builds with `-D warnings`, so
|
||||
/// without this the host and tray clippy legs fail to compile the lib at all.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub(crate) fn wire_sequence(bitstream: &[u8], packet_offset: usize) -> Option<u8> {
|
||||
let lo = *bitstream.get(packet_offset + 2)?;
|
||||
let hi = *bitstream.get(packet_offset + 3)?;
|
||||
Some(((u16::from_le_bytes([lo, hi]) >> 12) & 0x7) as u8)
|
||||
}
|
||||
|
||||
/// The wavelet block space's total 32x32-block count for a mode — the exact counting walk of
|
||||
/// upstream `WaveletBuffers::init_block_meta` (also ported to the Apple `WaveletLayout`, whose
|
||||
/// golden tests pin it against real host AUs). Needed because the vendored RDO pass packs the
|
||||
@@ -201,6 +227,193 @@ pub(crate) fn build_au(
|
||||
au
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streamed-AU chunk cutting (PW6 — latency plan §T3.4, wave-2 plan PW6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default per-chunk target — ~3–4 chunks for a 400 Mb/s 60 fps AU (~833 KB). Deliberately coarse,
|
||||
/// because the SEALER, not this size, sets how early bytes actually leave:
|
||||
///
|
||||
/// * Toward a plain `VIDEO_CAP_STREAMED_AU` client, `Packetizer::push_streamed` flushes only when
|
||||
/// its pending buffer exceeds one FEC block — `fec.max_data_per_block × shard_payload`, which is
|
||||
/// 200 × 1408 = 281 600 B on the shipped 1500-MTU IPv4 geometry. Anything smaller than that is
|
||||
/// simply buffered. (256 KiB sits just under one block, so the first flush lands on the SECOND
|
||||
/// chunk; the win is intact either way — the whole-AU path seals all ~3 blocks before its first
|
||||
/// datagram may leave.) Only a client that ALSO negotiated `VIDEO_CAP_MULTI_SLICE` gets the
|
||||
/// finer `MIN_STREAM_BLOCK_SHARDS` floor (16 shards ≈ 22 KB), where the chunk size does set the
|
||||
/// flush granularity directly. pf-encode is not told the session's FEC geometry, so this is a
|
||||
/// fixed byte target rather than a block-derived one.
|
||||
/// * Chunks are not free: the send thread paces each sealed batch on its own
|
||||
/// (`stream.rs::pace_sealed`), and every call grants a fresh `max(bytes/4, 128 KiB)` microburst
|
||||
/// allowance. Cutting an AU into dozens of chunks therefore erodes the pacing this host does to
|
||||
/// stop line-rate bursts from overrunning the NIC — the failure mode the pacer exists for.
|
||||
const STREAM_CHUNK_TARGET_BYTES: usize = 256 * 1024;
|
||||
/// Clamp on the `PUNKTFUNK_PYROWAVE_CHUNK_KIB` override (see [`stream_chunk_step`]).
|
||||
const STREAM_CHUNK_MIN_KIB: usize = 4;
|
||||
const STREAM_CHUNK_MAX_KIB: usize = 8192;
|
||||
|
||||
/// Whether streamed-AU output is armed for this host process.
|
||||
///
|
||||
/// **Default OFF, and deliberately so.** The streamed wire shape costs one PyroWave-specific
|
||||
/// regression that has not been measured: an UNPINNED streamed frame (its final block never
|
||||
/// arrived, so `frame_bytes` is still the 0 sentinel) is excluded from partial delivery
|
||||
/// (`reassemble.rs`, 2026-07 security-review finding 10) — where today's whole-AU path hands the
|
||||
/// consumer a usable blurred partial, a streamed frame that loses its final block delivers
|
||||
/// NOTHING. PyroWave clients opt into partial delivery unconditionally
|
||||
/// (`client/pump/handshake.rs`), so this is a live behaviour change for every one of them. The
|
||||
/// netem loss-harness leg (2 % on `lo`, FEC pinned off — the Phase-4 recipe) comparing
|
||||
/// partial-delivery rates streamed vs whole-AU is the prerequisite for flipping the default;
|
||||
/// until it has run, `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` is how you get it.
|
||||
///
|
||||
/// The client's `VIDEO_CAP_STREAMED_AU` and the host's `PUNKTFUNK_STREAMED_AU` remain the outer
|
||||
/// gates (`stream.rs`) — this only decides whether the ENCODER offers chunks at all.
|
||||
fn stream_armed() -> bool {
|
||||
static ARMED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
// Latched once: `supports_chunked_poll` is re-queried per AU, and a knob that could change
|
||||
// mid-session would flip the wire shape under an open `StreamedAu`.
|
||||
*ARMED.get_or_init(|| {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_PYROWAVE_STREAMED_AU").as_deref(),
|
||||
Ok("1")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes per streamed chunk, rounded DOWN to a whole number of `window`-sized windows (never
|
||||
/// below one). The rounding is the whole point — see [`AuChunker`].
|
||||
fn chunk_step(window: usize, target: usize) -> usize {
|
||||
(target / window.max(1)).max(1) * window.max(1)
|
||||
}
|
||||
|
||||
/// The streamed-AU chunk size for a backend whose wire chunking is `wire_chunk`, or `None` when
|
||||
/// this session must stay on the whole-AU path — which is the answer whenever the feature is not
|
||||
/// armed ([`stream_armed`]) or the encoder is in DENSE mode.
|
||||
///
|
||||
/// Dense mode is excluded on purpose: there the AU is ONE atomic pyrowave packet with no window
|
||||
/// framing, so a cut is neither shard-aligned nor a framing boundary. Every real PyroWave session
|
||||
/// runs datagram-aligned (`stream.rs` sets `plan.wire_chunk = Some(session.shard_payload())`), so
|
||||
/// nothing is lost — but the invariant this file promises stays true instead of nearly true.
|
||||
///
|
||||
/// `PUNKTFUNK_PYROWAVE_CHUNK_KIB` overrides the target (clamped to
|
||||
/// [`STREAM_CHUNK_MIN_KIB`]..=[`STREAM_CHUNK_MAX_KIB`]); garbage falls back to the default.
|
||||
pub(crate) fn stream_chunk_step(wire_chunk: Option<usize>) -> Option<usize> {
|
||||
let window = wire_chunk.filter(|&w| w > 0)?;
|
||||
if !stream_armed() {
|
||||
return None;
|
||||
}
|
||||
static TARGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
let target = *TARGET.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_PYROWAVE_CHUNK_KIB")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.filter(|k| (STREAM_CHUNK_MIN_KIB..=STREAM_CHUNK_MAX_KIB).contains(k))
|
||||
.map(|k| k * 1024)
|
||||
.unwrap_or(STREAM_CHUNK_TARGET_BYTES)
|
||||
});
|
||||
Some(chunk_step(window, target))
|
||||
}
|
||||
|
||||
/// Hands a **finished** datagram-aligned AU out in window-aligned pieces for the streamed-AU wire
|
||||
/// ([`crate::Encoder::poll_chunk`], `punktfunk_core::quic::VIDEO_CAP_STREAMED_AU`). Shared by both
|
||||
/// pyrowave backends so the cut rule cannot drift between Linux and Windows — the Windows backend
|
||||
/// cannot even be compiled from a Linux/macOS dev box, so logic written into it directly ships
|
||||
/// unverified.
|
||||
///
|
||||
/// ## What this does NOT buy (read before quoting PW6 as a latency win)
|
||||
///
|
||||
/// pyrowave's `encode_frame` is **synchronous**: `submit` returns only once the whole AU sits in
|
||||
/// `pending`, so by the time the host can poll a chunk the encode is over. `poll_chunk` is
|
||||
/// therefore NOT "emit slices as the encoder produces them" — it is "hand the finished AU out in
|
||||
/// pieces so the wire work pipelines with itself". Concretely, what moves:
|
||||
///
|
||||
/// * whole-AU path: `Session::seal_frame_at` FEC-protects, packetizes and AEAD-seals the ENTIRE
|
||||
/// ~830 KB AU before its first datagram may leave the socket;
|
||||
/// * streamed path: each FEC block seals and paces as it completes, so the first byte reaches the
|
||||
/// wire after one block's seal, and the remaining seal work overlaps its own transmission.
|
||||
///
|
||||
/// There is NO encode/send overlap here — unlike the H.26x sub-frame slice path, where chunks
|
||||
/// genuinely appear while the encoder is still working. PW6 and PW5 (encode overlap) are
|
||||
/// independent packages, not sequential ones.
|
||||
///
|
||||
/// It also does **not** give the client decode-while-arriving: the reassembler completes a
|
||||
/// streamed AU exactly like a whole one (`reassemble.rs` — `block_count != 0 && blocks_ok ==
|
||||
/// block_count`) and hands up ONE `Frame`. Client-side prefix decode is the separate
|
||||
/// `Session::set_deliver_frame_parts` opt-in, which PyroWave's newest-wins frame channel cannot
|
||||
/// take — see the PW6 section of `design/linux-host-performance-wave2-pyrowave.md`.
|
||||
///
|
||||
/// ## The cut rule
|
||||
///
|
||||
/// A chunk is a whole number of `chunk`-sized WINDOWS. [`build_au`] gives every window exactly ONE
|
||||
/// `kind` in its 4-byte prefix (`WIN_PACKED` or one link of a `WIN_FRAG_*` chain), so a cut inside
|
||||
/// a window would split a unit the clients parse atomically. Whole windows are `shard_payload`
|
||||
/// multiples by construction, which is what makes the sealer's sentinel block bases shard-aligned
|
||||
/// for free (plan §4.4) — the streamed path's placement contract.
|
||||
pub(crate) struct AuChunker {
|
||||
au: Vec<u8>,
|
||||
/// Bytes already handed out.
|
||||
cursor: usize,
|
||||
/// Bytes per chunk — a whole number of windows ([`chunk_step`]).
|
||||
step: usize,
|
||||
pts_ns: u64,
|
||||
keyframe: bool,
|
||||
recovery_anchor: bool,
|
||||
chunk_aligned: bool,
|
||||
/// Set once anything has been emitted, so the degenerate EMPTY AU still owes exactly one
|
||||
/// chunk and not an infinite stream of them.
|
||||
emitted: bool,
|
||||
}
|
||||
|
||||
impl AuChunker {
|
||||
pub(crate) fn new(frame: crate::EncodedFrame, step: usize) -> AuChunker {
|
||||
AuChunker {
|
||||
au: frame.data,
|
||||
cursor: 0,
|
||||
step: step.max(1),
|
||||
pts_ns: frame.pts_ns,
|
||||
keyframe: frame.keyframe,
|
||||
recovery_anchor: frame.recovery_anchor,
|
||||
chunk_aligned: frame.chunk_aligned,
|
||||
emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next piece, or `None` once the AU is spent. The pieces concatenate to exactly the bytes
|
||||
/// [`crate::Encoder::poll`] would have returned; `first` opens the wire frame and `last` closes
|
||||
/// it (the host's `handle_chunk` keys its `begin`/`finish` off precisely those two).
|
||||
pub(crate) fn next(&mut self) -> Option<crate::AuChunk> {
|
||||
if self.cursor >= self.au.len() {
|
||||
// A zero-byte AU is not reachable through `build_au` (it always emits at least one
|
||||
// window), but the host would leak its open `StreamedAu` if a chunked poll returned
|
||||
// nothing at all — so the degenerate case still owes one self-closing chunk.
|
||||
if self.emitted {
|
||||
return None;
|
||||
}
|
||||
self.emitted = true;
|
||||
return Some(self.chunk(Vec::new(), true, true));
|
||||
}
|
||||
let first = self.cursor == 0;
|
||||
let end = (self.cursor + self.step).min(self.au.len());
|
||||
let data = self.au[self.cursor..end].to_vec();
|
||||
self.cursor = end;
|
||||
self.emitted = true;
|
||||
Some(self.chunk(data, first, end == self.au.len()))
|
||||
}
|
||||
|
||||
/// AU-level metadata rides every chunk (the `AuChunk` contract only makes it authoritative on
|
||||
/// `first`, but a truthful copy on each one costs nothing and keeps a mid-AU log honest).
|
||||
fn chunk(&self, data: Vec<u8>, first: bool, last: bool) -> crate::AuChunk {
|
||||
crate::AuChunk {
|
||||
data,
|
||||
pts_ns: self.pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
recovery_anchor: self.recovery_anchor,
|
||||
chunk_aligned: self.chunk_aligned,
|
||||
first,
|
||||
last,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -362,4 +575,119 @@ mod tests {
|
||||
stamp_color_bits(&mut bs, 0, true);
|
||||
assert_eq!(bs[7], 0x78);
|
||||
}
|
||||
|
||||
// --- streamed-AU chunk cutting (PW6) ------------------------------------
|
||||
// Appended at module END per the wave plan's ownership rule.
|
||||
|
||||
fn frame(data: Vec<u8>) -> crate::EncodedFrame {
|
||||
crate::EncodedFrame {
|
||||
data,
|
||||
pts_ns: 1_234_567,
|
||||
keyframe: true,
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain a chunker into `(concatenated bytes, per-chunk lengths, first flags, last flags)`.
|
||||
fn drain(mut c: AuChunker) -> (Vec<u8>, Vec<usize>, Vec<bool>, Vec<bool>) {
|
||||
let (mut bytes, mut lens, mut firsts, mut lasts) = (Vec::new(), Vec::new(), vec![], vec![]);
|
||||
while let Some(ch) = c.next() {
|
||||
lens.push(ch.data.len());
|
||||
firsts.push(ch.first);
|
||||
lasts.push(ch.last);
|
||||
bytes.extend_from_slice(&ch.data);
|
||||
assert_eq!(ch.pts_ns, 1_234_567, "AU metadata rides every chunk");
|
||||
assert!(ch.keyframe && ch.chunk_aligned && !ch.recovery_anchor);
|
||||
}
|
||||
(bytes, lens, firsts, lasts)
|
||||
}
|
||||
|
||||
/// The invariant PW6 rests on: chunks concatenate to EXACTLY the AU, every cut lands on a
|
||||
/// whole-window boundary (so no window's single `kind` is split across two wire frames), and
|
||||
/// the reassembled stream still walks back to the same codec packets. A cut inside a window
|
||||
/// would hand the client a 4-byte prefix whose body arrives in a different chunk — the
|
||||
/// framing is one-kind-per-window, so there is no way to express that.
|
||||
#[test]
|
||||
fn stream_chunks_tile_the_au_on_window_boundaries() {
|
||||
let bs: Vec<u8> = (0..4000u32).map(|i| (i % 251) as u8).collect();
|
||||
let packets = [(0, 20), (20, 300), (320, 55), (375, 900), (1275, 40)];
|
||||
let chunk = 64;
|
||||
let au = build_au(&packets, &bs, Some(chunk));
|
||||
assert!(au.len() / chunk > 4, "need several windows to cut between");
|
||||
let step = chunk_step(chunk, 3 * chunk);
|
||||
assert_eq!(step, 3 * chunk);
|
||||
let (bytes, lens, firsts, lasts) = drain(AuChunker::new(frame(au.clone()), step));
|
||||
assert_eq!(bytes, au, "chunks concatenate to exactly the AU");
|
||||
assert!(
|
||||
lens.iter().all(|l| l % chunk == 0),
|
||||
"every chunk is a whole number of windows: {lens:?}"
|
||||
);
|
||||
assert!(
|
||||
lens[..lens.len() - 1].iter().all(|&l| l == step),
|
||||
"only the tail chunk may be short: {lens:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
firsts,
|
||||
(0..lens.len()).map(|i| i == 0).collect::<Vec<_>>(),
|
||||
"exactly one opening chunk"
|
||||
);
|
||||
assert_eq!(
|
||||
lasts,
|
||||
(0..lens.len())
|
||||
.map(|i| i + 1 == lens.len())
|
||||
.collect::<Vec<_>>(),
|
||||
"exactly one closing chunk"
|
||||
);
|
||||
// And the client's parse is unchanged by the cutting.
|
||||
let mut expect = Vec::new();
|
||||
for &(o, s) in &packets {
|
||||
expect.extend_from_slice(&bs[o..o + s]);
|
||||
}
|
||||
assert_eq!(walk(&bytes, chunk), expect);
|
||||
}
|
||||
|
||||
/// The step always rounds DOWN to whole windows and never to zero — a target below one window
|
||||
/// degenerates to one window per chunk rather than an empty chunk (which would spin forever).
|
||||
#[test]
|
||||
fn chunk_step_rounds_down_to_whole_windows() {
|
||||
// 262144 / 1408 = 186.2 → 186 whole windows (261 888 B), never the 262 144 asked for.
|
||||
assert_eq!(chunk_step(1408, 256 * 1024), 186 * 1408);
|
||||
assert_eq!(chunk_step(1408, 1408), 1408);
|
||||
assert_eq!(chunk_step(1408, 1407), 1408); // below one window → one window
|
||||
assert_eq!(chunk_step(1408, 0), 1408);
|
||||
assert_eq!(chunk_step(0, 4096), 4096); // defensive: never divides by zero
|
||||
}
|
||||
|
||||
/// An AU that fits one chunk is a single `first && last` piece — the shape the host's
|
||||
/// `handle_chunk` turns into begin+finish on one message, and byte-identical on the wire to
|
||||
/// what the whole-AU path would have sealed.
|
||||
#[test]
|
||||
fn single_chunk_au_opens_and_closes_itself() {
|
||||
let au = vec![7u8; 512];
|
||||
let (bytes, lens, firsts, lasts) = drain(AuChunker::new(frame(au.clone()), 4096));
|
||||
assert_eq!(bytes, au);
|
||||
assert_eq!(lens, vec![512]);
|
||||
assert_eq!(firsts, vec![true]);
|
||||
assert_eq!(lasts, vec![true]);
|
||||
}
|
||||
|
||||
/// The degenerate empty AU still owes exactly ONE self-closing chunk: a chunked poll that
|
||||
/// returned nothing would leave the host's `StreamedAu` open forever (its `begin` fires on
|
||||
/// `first`, its `finish` on `last`).
|
||||
#[test]
|
||||
fn empty_au_still_emits_one_self_closing_chunk() {
|
||||
let mut c = AuChunker::new(frame(Vec::new()), 4096);
|
||||
let ch = c.next().expect("one chunk");
|
||||
assert!(ch.first && ch.last && ch.data.is_empty());
|
||||
assert!(c.next().is_none(), "and never a second one");
|
||||
}
|
||||
|
||||
/// Dense (non-windowed) AUs never stream: there is no window framing to cut on, so a chunk
|
||||
/// boundary would be neither shard-aligned nor a parse boundary.
|
||||
#[test]
|
||||
fn dense_mode_never_streams() {
|
||||
assert!(stream_chunk_step(None).is_none());
|
||||
assert!(stream_chunk_step(Some(0)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,11 @@ pub struct PyroWaveEncoder {
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
/// The AU currently being handed out in streamed chunks (PW6 — `Some` strictly between a
|
||||
/// `first` chunk and its `last`). See [`pyrowave_wire::AuChunker`]: this backend's encode is
|
||||
/// synchronous, so the AU is COMPLETE before the first chunk leaves — the split is for the
|
||||
/// send side, never an encode/send overlap.
|
||||
chunker: Option<pyrowave_wire::AuChunker>,
|
||||
}
|
||||
|
||||
// SAFETY: used only from the single encode thread; the pyrowave handles are owned and only touched
|
||||
@@ -255,6 +260,7 @@ impl PyroWaveEncoder {
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
chunker: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -676,10 +682,55 @@ impl Encoder for PyroWaveEncoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// Trait contract: each AU is drained through ONE method. Erroring beats double-emitting
|
||||
// the bytes the chunk cursor already handed out (which would reach the wire twice, under
|
||||
// the same frame index, and fail the receiver's retro-validation).
|
||||
if self.chunker.is_some() {
|
||||
bail!("pyrowave: poll() on an AU already being drained through poll_chunk");
|
||||
}
|
||||
Ok(self.pending.pop_front())
|
||||
}
|
||||
|
||||
// --- streamed AU (PW6) — see `pyrowave_wire::AuChunker` for what this does and does NOT buy.
|
||||
// Byte-identical to the Linux twin BY CONSTRUCTION: all of the cutting lives in the shared
|
||||
// helper, which compiles and unit-tests on every platform. This file cannot be compiled from
|
||||
// a Linux/macOS dev box, so anything written here directly would ship unverified.
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
pyrowave_wire::stream_chunk_step(self.wire_chunk).is_some()
|
||||
}
|
||||
|
||||
fn poll_chunk(&mut self) -> Result<Option<crate::AuChunk>> {
|
||||
// Finish the AU already in flight before opening the next one — the host's `handle_chunk`
|
||||
// keys begin/finish off `first`/`last` and cannot interleave two AUs.
|
||||
if let Some(c) = self.chunker.as_mut() {
|
||||
if let Some(chunk) = c.next() {
|
||||
return Ok(Some(chunk));
|
||||
}
|
||||
self.chunker = None;
|
||||
}
|
||||
let Some(f) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// No blocking wait here (the trait allows one): `submit` already ran the whole encode
|
||||
// synchronously, so an AU in `pending` is complete by construction.
|
||||
match pyrowave_wire::stream_chunk_step(self.wire_chunk) {
|
||||
Some(step) => Ok(self
|
||||
.chunker
|
||||
.insert(pyrowave_wire::AuChunker::new(f, step))
|
||||
.next()),
|
||||
// Unarmed / dense: the trait's own default shape, so a host that polls chunks anyway
|
||||
// still gets whole AUs.
|
||||
None => Ok(Some(crate::AuChunk::whole(f))),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) -> bool {
|
||||
// A rebuild forfeits every in-flight frame — including an AU only half-handed-out through
|
||||
// `poll_chunk`. Dropping the cursor here (ahead of every `pending.clear()` arm below) is
|
||||
// what keeps the next `poll_chunk` from splicing the tail of a dead AU onto a fresh one;
|
||||
// the host sees a `first` without the previous `last`, logs "streamed AU abandoned
|
||||
// mid-flight" and lets the client age that frame out.
|
||||
self.chunker = None;
|
||||
// Cheap in-place rebuild: recreate only the pyrowave encoder object (no rate-control /
|
||||
// reference state to preserve). The device, imported textures and fence survive.
|
||||
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -646,6 +646,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// translation automatically — the GTK launcher never turned it off either).
|
||||
gamepad.set_menu_mode(true);
|
||||
}
|
||||
// Gaming Mode's Steam menu / QAM drive the SAME physical pad we forward, and gamescope
|
||||
// never takes our X focus away (it resolves focus per Xwayland ctx, and we are alone in
|
||||
// ours), so SDL's own background-input gate cannot fire there. `None` everywhere else,
|
||||
// where window focus IS the signal — see the FocusLost/FocusGained arms below.
|
||||
#[cfg(target_os = "linux")]
|
||||
let overlay_focus = pf_client_core::overlay_focus::OverlayFocus::start();
|
||||
// Two independent reasons the pad is not ours — window focus and the gamescope overlay —
|
||||
// OR'd into ONE value that is pushed to the service on an edge. Kept as separate inputs
|
||||
// rather than one flag each source writes: either would otherwise clear the other's mask
|
||||
// (a focus-loss mask undone by the next overlay poll saying "no overlay", and vice versa).
|
||||
let mut focus_lost = false;
|
||||
let mut mask_applied = false;
|
||||
|
||||
// The native display mode — the `0 = native` fallback for the requested stream mode
|
||||
// (the GTK client reads the monitor under its window; same idea).
|
||||
@@ -758,8 +770,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
tracing::info!("focus lost — input released");
|
||||
}
|
||||
}
|
||||
// Controllers go with the keyboard and mouse. SDL already stops
|
||||
// delivering their PRESSES here, but nothing zeroed what the host
|
||||
// still believes is held — so a stick deflected at the moment focus
|
||||
// went away kept steering. Masking flushes it neutral.
|
||||
focus_lost = true;
|
||||
}
|
||||
WindowEvent::FocusGained => {
|
||||
// Unlike capture, the controller mask has no "the user meant it"
|
||||
// variant to respect — it exists only to mirror who owns the pad —
|
||||
// so regaining focus always lifts its half.
|
||||
focus_lost = false;
|
||||
// An auto-release (Alt-Tab) undoes itself; a chord release
|
||||
// stays released until the user opts back in.
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
@@ -1070,6 +1091,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
other => pump.handle_event(other),
|
||||
}
|
||||
}
|
||||
// Who owns the pad right now: window focus, plus Gaming Mode's overlay signal where it
|
||||
// exists (one relaxed atomic load; `None` off gamescope). Edge-triggered — the service
|
||||
// hears only about CHANGES, so an open QAM doesn't re-flush the pads every iteration.
|
||||
#[cfg(target_os = "linux")]
|
||||
let overlay_now = overlay_focus.as_ref().is_some_and(|of| of.is_open());
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let overlay_now = false;
|
||||
let want_mask = focus_lost || overlay_now;
|
||||
if want_mask != mask_applied {
|
||||
mask_applied = want_mask;
|
||||
gamepad.set_masked(want_mask);
|
||||
}
|
||||
pump.tick();
|
||||
// One coalesced MouseMove per iteration — pure motion must reach the host
|
||||
// without waiting for a click/key to flush it.
|
||||
|
||||
@@ -130,6 +130,22 @@ impl Compositor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this backend need a compositor that is ALREADY RUNNING for this uid?
|
||||
///
|
||||
/// Every desktop backend attaches to a live session — it asks Mutter/KWin/sway/Hyprland to mint
|
||||
/// a virtual output over their IPC, so with nothing running there is no one to ask and `create`
|
||||
/// can only fail (on GNOME: `RemoteDesktop.CreateSession:
|
||||
/// org.freedesktop.DBus.Error.ServiceUnknown`). [`Compositor::Gamescope`] is the exception: it
|
||||
/// stands its own session up from nothing (bare headless spawn / managed takeover), which is
|
||||
/// exactly why a headless box pins to it.
|
||||
///
|
||||
/// Callers use this to tell "the session is up" from "the session is a corpse" BEFORE marching a
|
||||
/// client into a doomed bring-up — the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so it never returns on its own).
|
||||
pub fn needs_live_session(self) -> bool {
|
||||
!matches!(self, Compositor::Gamescope)
|
||||
}
|
||||
|
||||
/// Human label for UIs.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
|
||||
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
|
||||
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
|
||||
//! connects. The headless test path instead exposes it to bare clients via
|
||||
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! connects. That identification is also why **the host binary must carry no file capability**: a
|
||||
//! process holding capabilities KWin lacks is one the kernel will not let KWin resolve
|
||||
//! `/proc/<pid>/exe` for, so it can never be matched to a `.desktop` no matter how correctly the
|
||||
//! file is installed (see [`capability_denial_hint`]). The headless test path instead exposes it to
|
||||
//! bare clients via `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
|
||||
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
|
||||
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
|
||||
@@ -1071,6 +1074,74 @@ impl Drop for StopOnDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra sentence appended to every "KWin never advertised the screencast global" error when this
|
||||
/// process carries capabilities — the one cause that is completely invisible from the Wayland side.
|
||||
///
|
||||
/// KWin authorizes a restricted interface by resolving the *client's* `/proc/<pid>/exe` and
|
||||
/// matching it against an installed `.desktop`. The kernel refuses that readlink to any reader
|
||||
/// whose effective set is not a superset of the target's **permitted** set
|
||||
/// (`cap_ptrace_access_check`), and KWin has no capabilities at all. So a host binary carrying any
|
||||
/// file capability is simply unidentifiable: `executablePath()` comes back empty, no `.desktop` can
|
||||
/// match, and the global is never advertised — indistinguishable, from here, from a missing
|
||||
/// `.desktop`. Neither half of the obvious workaround helps: `prctl(PR_SET_DUMPABLE, 1)` leaves the
|
||||
/// permitted-set check failing, and moving the grant to systemd `AmbientCapabilities=` lands the
|
||||
/// capability in the same permitted set. Only an uncapped binary is identifiable.
|
||||
///
|
||||
/// This is not hypothetical: 0.26.0-1 setcap'd `cap_sys_nice` on the host for the GPU-priority
|
||||
/// lever and took out desktop streaming on every KDE box until the capability was removed again.
|
||||
fn capability_denial_hint() -> String {
|
||||
let permitted = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|status| permitted_caps_from_status(&status));
|
||||
match permitted {
|
||||
Some(caps) if caps != 0 => format!(
|
||||
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
|
||||
its own to cause this: the kernel then refuses KWin the /proc/<pid>/exe read it \
|
||||
identifies clients by, so no .desktop can match however correctly it is installed. \
|
||||
Clear them with `sudo setcap -r /usr/bin/punktfunk-host` and restart the host"
|
||||
),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The permitted-capability mask out of a `/proc/<pid>/status` body, or `None` if the field is
|
||||
/// absent/unparseable. The kernel prints it as a tab-separated 16-digit hex word with no `0x`
|
||||
/// (`CapPrm:\t0000000000800000` = CAP_SYS_NICE), which is what the split-and-radix-16 parse below
|
||||
/// expects — split out from [`capability_denial_hint`] purely so that shape is testable without a
|
||||
/// capability-carrying process to point at.
|
||||
fn permitted_caps_from_status(status: &str) -> Option<u64> {
|
||||
let field = status.lines().find(|l| l.starts_with("CapPrm:"))?;
|
||||
u64::from_str_radix(field.split_whitespace().nth(1)?, 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_hint_tests {
|
||||
use super::*;
|
||||
|
||||
/// Verbatim from a `cap_sys_nice=ep` process on CachyOS — the case that broke 0.26.0-1.
|
||||
const CAPPED: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000800000\nCapEff:\t0000000000800000\n";
|
||||
/// ...and from the same binary with no capability, where the hint must stay silent.
|
||||
const CLEAN: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\n";
|
||||
|
||||
#[test]
|
||||
fn parses_the_kernels_permitted_mask() {
|
||||
assert_eq!(permitted_caps_from_status(CAPPED), Some(0x0080_0000));
|
||||
assert_eq!(permitted_caps_from_status(CLEAN), Some(0));
|
||||
// CapPrm is not guaranteed present (older/again-different kernels): stay quiet, never panic.
|
||||
assert_eq!(permitted_caps_from_status("Name:\tx\n"), None);
|
||||
assert_eq!(permitted_caps_from_status("CapPrm:\tzzzz\n"), None);
|
||||
assert_eq!(permitted_caps_from_status("CapPrm:\n"), None);
|
||||
}
|
||||
|
||||
/// A capability-free host must not append the hint — the message it decorates is also printed
|
||||
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
|
||||
/// send the reader chasing a setcap that was never there. The test process has no capabilities.
|
||||
#[test]
|
||||
fn silent_without_capabilities() {
|
||||
assert_eq!(capability_denial_hint(), "");
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
|
||||
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
|
||||
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
|
||||
@@ -1090,7 +1161,8 @@ pub fn probe() -> Result<()> {
|
||||
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
|
||||
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
|
||||
re-reads it — the grant is cached per-exe on first connect), or set \
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6"
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6{}",
|
||||
capability_denial_hint()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1134,7 +1206,9 @@ fn run_existing(
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
|
||||
test){}",
|
||||
capability_denial_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1223,7 +1297,9 @@ fn run(
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
|
||||
test){}",
|
||||
capability_denial_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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,35 +421,13 @@ pub fn hw_cursor_capable() -> bool {
|
||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||
}
|
||||
|
||||
/// Is NO session currently streaming to a virtual display?
|
||||
///
|
||||
/// The safety question for anything that tears the adapter down — notably
|
||||
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` takes every
|
||||
/// monitor on the adapter with it. Only [`SlotState::Active`] counts: that is a session with live
|
||||
/// references, and destroying its monitor mid-stream is the cross-session damage worth refusing.
|
||||
///
|
||||
/// `Lingering`/`Pinned` slots deliberately do NOT count. They are keep-alive monitors with no
|
||||
/// session attached, and a reconnect **already** preempts and recreates them — "a reused IddCx
|
||||
/// swap-chain is dead" (see [`SlotState::Pinned`]) — so a device restart destroys nothing the
|
||||
/// reconnect was not going to destroy anyway. Counting them was too conservative to be useful: the
|
||||
/// case this gate exists for is exactly *disconnect from a desktop session, reconnect in capture
|
||||
/// mode*, and the disconnected session's monitor is lingering at precisely that moment, so the
|
||||
/// clean-up could never fire when it was most wanted (observed on `.173`, 2026-08-08).
|
||||
pub fn no_active_sessions() -> bool {
|
||||
match VDM.get() {
|
||||
// Before the first backend open there is nothing to protect.
|
||||
None => true,
|
||||
Some(m) => !m
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.slots
|
||||
.values()
|
||||
.any(|s| matches!(s, SlotState::Active { .. })),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -525,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()?;
|
||||
@@ -547,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,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).
|
||||
@@ -685,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));
|
||||
}
|
||||
@@ -721,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
|
||||
@@ -762,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 {
|
||||
@@ -843,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
|
||||
@@ -859,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?,
|
||||
};
|
||||
@@ -915,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
|
||||
@@ -1925,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(
|
||||
@@ -2008,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));
|
||||
@@ -2079,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) };
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2246,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
|
||||
@@ -158,226 +195,6 @@ enum AdapterCycle {
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Restart the pf-vdisplay device to CLEAR a sticky IddCx hardware-cursor declare, so sessions that
|
||||
/// do not want the host to own the pointer get the OS's own cursor compositing back (full fidelity,
|
||||
/// zero host cost — no GDI poller, no per-frame blend, true XOR instead of our outline
|
||||
/// approximation).
|
||||
///
|
||||
/// **Why this exists.** A hardware-cursor declare is irrevocable and ADAPTER-WIDE
|
||||
/// (`pf-driver-proto` v6 note): once any desktop-mode session declares, DWM stops compositing the
|
||||
/// pointer into EVERY later frame on that adapter, and every subsequent session — including
|
||||
/// capture-latched ones that never asked for a cursor channel — has to self-composite. The state
|
||||
/// lives in the driver's `DECLARED_TARGETS`, whose scope is the WUDFHost process, so recycling that
|
||||
/// process clears it.
|
||||
///
|
||||
/// **Why `/restart-device` and not the [`reload_vdisplay_adapter`] cycle.** Measured on-glass
|
||||
/// 2026-08-08 (`.173`): `pnputil /restart-device` returned in **0.07 s** with a NEW WUDFHost pid,
|
||||
/// against ~6 s of sleeps for `Disable`+`Enable` — and, being designed for a device that is in use,
|
||||
/// it does not hit the refusal that doc calls "the expected case here". It also repaired an adapter
|
||||
/// found in `CM_PROB_FAILED_POST_START` (Code 43) in the same call.
|
||||
///
|
||||
/// ⚠⚠ **This is a ONCE-PER-BOOT lever, not a cheap one.** Measured on `.173` 2026-08-08: the first
|
||||
/// `/restart-device` after a cold boot succeeds in 0.07 s; every later one in the same boot fails
|
||||
/// with *"Das System muss neu gestartet werden, damit Konfigurationsvorgänge abgeschlossen
|
||||
/// werden"*, and repeated attempts additionally push the devnode into `restart pending`. So this
|
||||
/// can clean the adapter at host start-up and nowhere else — anything wanting to un-declare
|
||||
/// mid-boot (e.g. giving a capture session back the lossless pointer after a desktop session) needs
|
||||
/// a different mechanism to recycle the driver's WUDFHost process, which is where the declare
|
||||
/// actually lives.
|
||||
///
|
||||
/// ⚠ It tears the adapter down, so it must run only when NO session holds a display — the host
|
||||
/// start-up path. `PUNKTFUNK_CURSOR_CLEAN_START=0` disables it.
|
||||
///
|
||||
/// Returns `true` only when pnputil reported success. Best-effort: a failure just leaves the
|
||||
/// adapter as it was (sessions then self-composite exactly as before).
|
||||
/// The driver's WUDFHost pid, from the most recent ADD reply. `0` before any monitor was created.
|
||||
static LAST_WUDF_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Clear a sticky hardware-cursor declare by recycling the driver's WUDFHost process.
|
||||
///
|
||||
/// The declare is irrevocable and adapter-wide, but its scope is the WUDFHost process
|
||||
/// (`monitor.rs` `DECLARED_TARGETS`) — so killing that process drops it. WUDF respawns the host on
|
||||
/// the next open, with a fresh adapter object.
|
||||
///
|
||||
/// **This is what makes un-declaring possible mid-boot.** `pnputil /restart-device` also works but
|
||||
/// is a ONCE-PER-BOOT operation (see [`restart_device_for_clean_cursor`]); the start-up clean
|
||||
/// spends it, leaving nothing for the desktop-session→reconnect case. Measured on `.173`
|
||||
/// 2026-08-08: pid 3872 → 19932, `adapter_luid` 0x8ed607 → 0x1a8f6ca, `cursor_excluded` true →
|
||||
/// **false**, next session streamed normally.
|
||||
///
|
||||
/// Same precondition as the device restart: no session may hold a display, because every monitor
|
||||
/// on the adapter dies with the host.
|
||||
fn recycle_wudfhost() -> bool {
|
||||
let pid = LAST_WUDF_PID.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if pid == 0 {
|
||||
tracing::info!("cursor: no driver host pid known yet — nothing to recycle");
|
||||
return false;
|
||||
}
|
||||
// taskkill rather than OpenProcess/TerminateProcess: the host runs as SYSTEM, so it already has
|
||||
// the rights, and shelling out keeps this off the unsafe-proof budget for a once-per-session
|
||||
// maintenance action.
|
||||
match std::process::Command::new(
|
||||
std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32 askkill.exe"))
|
||||
.unwrap_or_else(|_| "taskkill.exe".to_string()),
|
||||
)
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!(
|
||||
pid,
|
||||
"cursor: recycled the driver's WUDFHost — the hardware-cursor declare is gone"
|
||||
);
|
||||
LAST_WUDF_PID.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
pid,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr).trim().replace('\n', " "),
|
||||
"cursor: could not recycle the driver's WUDFHost — this session self-composites"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(pid, error = %e, "cursor: taskkill spawn failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this host process DECLARED an IddCx hardware cursor since the adapter was last restarted?
|
||||
/// Set by the ADD path when a session ASKS for a hardware cursor (the one place every declare
|
||||
/// passes through); cleared when the declare is dropped. The host's own mirror of the
|
||||
/// driver's `DECLARED_TARGETS` — cheaper than probing, and it only ever needs to be right about
|
||||
/// "did WE dirty it", because a declare from an earlier BOOT is handled by the start-up clean.
|
||||
static CURSOR_DECLARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Give the NEXT session back the lossless cursor: if an earlier session on this host declared the
|
||||
/// hardware cursor and this one does not want it, restart the device to clear the sticky declare.
|
||||
///
|
||||
/// This is the case the start-up clean cannot reach — **run a desktop-mode session, disconnect,
|
||||
/// reconnect in capture mode**. Same host process, so the adapter is still dirty from the first
|
||||
/// session and the capture session would self-composite the pointer for its whole life. Declaring
|
||||
/// is one-way and adapter-wide (`pf-driver-proto` v6), so the only way back is a device restart —
|
||||
/// 0.07 s, measured.
|
||||
///
|
||||
/// Must be called BEFORE this session creates its display, and only when nothing else holds one:
|
||||
/// the restart takes every monitor on the adapter with it.
|
||||
///
|
||||
/// Returns `true` only when it actually restarted.
|
||||
pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
if session_wants_declare || !CURSOR_DECLARED.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
// Gated deliberately — a device restart is NOT free. Windows puts the devnode into
|
||||
// "restart pending" after repeated cycles, and `/restart-device` then refuses with "a system
|
||||
// restart is pending for this device" until an actual reboot (hit on .173 2026-08-08 after ~6
|
||||
// restarts in one afternoon, which is also what made the earlier runs look like a wiring bug:
|
||||
// the call ran, the restart failed, and nothing logged the failure). So restart only when a
|
||||
// declare is actually outstanding, never speculatively.
|
||||
let previously_declared = true;
|
||||
// Refuse only while another session is STREAMING — a keep-alive (lingering/pinned) monitor has
|
||||
// no session attached and a reconnect recreates it regardless, so restarting the adapter costs
|
||||
// it nothing. Gating on keep-alive too made this dead code in the one case it exists for: after
|
||||
// a desktop session disconnects its monitor LINGERS, which is exactly when the next
|
||||
// capture-mode connect needs the declare gone (observed on .173).
|
||||
if !super::manager::no_active_sessions() {
|
||||
tracing::info!(
|
||||
"cursor: this session wants no hardware cursor and an earlier one declared, but a display is still held (live or keep-alive) — skipping the adapter restart, so the pointer stays host-composited for this session"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if recycle_wudfhost() {
|
||||
// The cached control handle died with the host process. Retire it so the next
|
||||
// `ensure_device` reopens against the respawned WUDFHost — without this the ADD that
|
||||
// follows runs on a stale handle and the session comes up with no frames at all.
|
||||
super::manager::invalidate_cached_device("cursor clean: recycled the driver host");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
CURSOR_DECLARED.store(false, Ordering::Relaxed);
|
||||
tracing::info!(
|
||||
previously_declared,
|
||||
"cursor: restarted the adapter for this capture-mode session — any hardware-cursor \
|
||||
declare is gone, so the OS composites the pointer itself (full fidelity, no host \
|
||||
blend). previously_declared=false only means the host-side hint was unset; the \
|
||||
restart is idempotent either way"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn restart_device_for_clean_cursor() -> bool {
|
||||
if std::env::var("PUNKTFUNK_CURSOR_CLEAN_START").is_ok_and(|v| v == "0") {
|
||||
tracing::info!(
|
||||
"pf-vdisplay: cursor clean-start disabled (PUNKTFUNK_CURSOR_CLEAN_START=0) — a sticky \
|
||||
hardware-cursor declare from an earlier boot will keep sessions self-compositing"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// `$LASTEXITCODE` is pre-seeded to 1 for the same reason `reload_vdisplay_adapter` does it: if
|
||||
// pnputil never launches, a stale value must not read as success.
|
||||
const 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 }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { $out = (& $pnp /restart-device $ad.InstanceId 2>&1 | Out-String) }; \
|
||||
if ($LASTEXITCODE -eq 0) { Write-Output 'RESTARTED' } \
|
||||
else { Write-Output ('FAILED ' + ($out -replace '\\s+', ' ')) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
let out = match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
PS,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: cursor clean-start could not spawn powershell");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
match out.as_str() {
|
||||
"RESTARTED" => {
|
||||
tracing::info!(
|
||||
"pf-vdisplay: restarted the adapter at start-up — any sticky hardware-cursor \
|
||||
declare is cleared, so sessions without a cursor channel get the OS's own \
|
||||
(full-fidelity, zero-cost) pointer compositing until one declares again"
|
||||
);
|
||||
true
|
||||
}
|
||||
"ABSENT" => false, // driver not installed — nothing to clean, and `open` reports that later
|
||||
// Keep pnputil's own text. The failure that actually occurs is "a system restart is
|
||||
// pending for this device" — no retry fixes it, and a bare exit code hid it for three runs.
|
||||
other => {
|
||||
tracing::warn!(
|
||||
outcome = other,
|
||||
// Two distinct wordings, both meaning "not until you reboot":
|
||||
// "Für das Gerät steht ein Systemneustart aus" (device restart pending)
|
||||
// "Das System muss neu gestartet werden, damit …" (config ops need a reboot)
|
||||
// The second is what you actually hit, and it appears after the FIRST successful
|
||||
// restart of a boot — see the doc on `restart_device_for_clean_cursor`.
|
||||
needs_reboot = other.contains("Systemneustart")
|
||||
|| other.contains("muss neu gestartet werden")
|
||||
|| other.to_ascii_lowercase().contains("restart is pending")
|
||||
|| other.to_ascii_lowercase().contains("must be restarted"),
|
||||
"pf-vdisplay: cursor clean-start did not restart the adapter — sessions without a \
|
||||
cursor channel will self-composite the pointer if an earlier declare is sticky"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
|
||||
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
|
||||
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
|
||||
@@ -398,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
|
||||
@@ -408,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 } \
|
||||
@@ -421,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());
|
||||
@@ -573,12 +409,6 @@ pub unsafe fn send_cursor_channel(
|
||||
dev: HANDLE,
|
||||
req: &control::SetCursorChannelRequest,
|
||||
) -> Result<()> {
|
||||
// THE declare point. The driver declares its IddCx hardware cursor when this channel arrives —
|
||||
// not from the ADD request's `hw_cursor` flag, which is why recording the declare there (and,
|
||||
// before that, in `capture_virtual_output`) left the flag false and the between-session clean
|
||||
// silently inert. The log line that names this moment is "cursor channel delivered - driver
|
||||
// declares the hardware cursor".
|
||||
CURSOR_DECLARED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per this fn's contract `dev` is the live control handle; `bytes_of(req)` borrows the
|
||||
// caller's request across this synchronous call; no output buffer.
|
||||
@@ -905,26 +735,6 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hw_cursor: bool,
|
||||
) -> Result<AddedMonitor> {
|
||||
// Give a capture-mode session the LOSSLESS pointer back: if an earlier session declared a
|
||||
// hardware cursor and this one does not want it, recycle the driver's host process BEFORE
|
||||
// this monitor is added. The ADD path is the only place guaranteed to see every session
|
||||
// (the handshake call site this replaced sat in a `match (source, compositor)` arm that is
|
||||
// not taken on this host, so it never ran).
|
||||
// ⚠ DISABLED BY DEFAULT — opt in with PUNKTFUNK_CURSOR_RECYCLE=1.
|
||||
//
|
||||
// The MECHANISM is proven (recycling the driver host clears the declare: measured pid
|
||||
// 3872→19932, adapter_luid 0x8ed607→0x1a8f6ca, cursor_excluded true→false, next session
|
||||
// streamed fine). What is NOT solved is calling it from HERE: `invalidate_cached_device`
|
||||
// takes the manager `device` mutex, which this ADD path already holds, so the session
|
||||
// DEADLOCKS — observed on .173, the ADD stops after SET_RENDER_ADAPTER and the client gets
|
||||
// "no frames received". Its own doc warns about exactly this.
|
||||
//
|
||||
// The fix is a call site that runs OUTSIDE the mutex and still on every session's path;
|
||||
// the handshake site tried before is not reached on this host. Until then this stays off:
|
||||
// a session that self-composites is the old behaviour, a deadlocked one is a regression.
|
||||
if !hw_cursor && std::env::var("PUNKTFUNK_CURSOR_RECYCLE").is_ok_and(|v| v == "1") {
|
||||
clean_cursor_for_next_session(false);
|
||||
}
|
||||
let session_id = next_session_id();
|
||||
// The client display's volume rides into the monitor's EDID CTA HDR block; all-zero =
|
||||
// unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which
|
||||
@@ -1070,14 +880,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
tracing::info!(
|
||||
target_id = reply.target_id,
|
||||
adapter_luid = %format_args!("{:#x}", luid.LowPart),
|
||||
wudf_pid = {
|
||||
// The declare lives in THIS process (monitor.rs `DECLARED_TARGETS`), so remember it:
|
||||
// recycling it is the only way to un-declare that does not cost the once-per-boot
|
||||
// device restart. Proven on .173 2026-08-08 — killing it gave a new host pid, a NEW
|
||||
// adapter luid, and `cursor_excluded=false`, with the next session streaming fine.
|
||||
LAST_WUDF_PID.store(reply.wudf_pid, std::sync::atomic::Ordering::Relaxed);
|
||||
reply.wudf_pid
|
||||
},
|
||||
wudf_pid = reply.wudf_pid,
|
||||
cursor_excluded = reply.cursor_excluded != 0,
|
||||
"pf-vdisplay monitor created {}x{}@{}",
|
||||
mode.width,
|
||||
@@ -1303,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
|
||||
@@ -1322,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)",
|
||||
@@ -1372,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.
|
||||
@@ -1448,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).
|
||||
@@ -1479,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
|
||||
|
||||
@@ -19,7 +19,7 @@ pub mod vkslot;
|
||||
pub mod vulkan;
|
||||
pub mod worker;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
pub use cuda::DeviceBuffer;
|
||||
pub use egl::{DmabufPlane, EglImporter};
|
||||
@@ -261,56 +261,223 @@ pub fn gpu_import_disabled() -> bool {
|
||||
/// operator found `PUNKTFUNK_ZEROCOPY=0` by hand. The host already knows how to encode that
|
||||
/// machine — capture just has to stop handing it dmabufs. Latching here is what makes the next
|
||||
/// session negotiate CPU frames on its own.
|
||||
static RAW_DMABUF_FAILURE_STREAK: AtomicU32 = AtomicU32::new(0);
|
||||
static RAW_DMABUF_DISABLED: AtomicBool = AtomicBool::new(false);
|
||||
/// Below the encoder's own rebuild budget, so the latch is set before the session it doomed ends.
|
||||
const RAW_DMABUF_FAILURE_LATCH: u32 = 3;
|
||||
|
||||
/// Record an encoder-side raw-dmabuf import failure. Latches the process-wide disable after
|
||||
/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures.
|
||||
/// Consecutive capture rebuilds whose dmabuf-only offer never negotiated before the passthrough is
|
||||
/// latched off. **2 = one retry**, deliberately: each failed negotiation costs a ~10 s stall, so a
|
||||
/// larger budget is paid by the user in dead air. One retry is enough to survive a compositor
|
||||
/// caught mid-restart, which is the transient this exists for; a compositor that genuinely never
|
||||
/// accepts keeps the same capture identity, so its streak accumulates and it latches on the second
|
||||
/// try — one extra stall versus the old behaviour, once per host lifetime.
|
||||
const RAW_DMABUF_NEGOTIATION_LATCH: u32 = 2;
|
||||
|
||||
/// The raw-dmabuf passthrough's off-switch — **two causes with two different lifetimes**, which is
|
||||
/// the whole point of this type.
|
||||
///
|
||||
/// They used to share one `AtomicBool`, so the cheap recoverable cause (a negotiation that timed
|
||||
/// out, possibly because the compositor was mid-restart) was as permanent as the expensive
|
||||
/// unrecoverable one (an encoder that cannot import what this compositor allocates). Once either
|
||||
/// fired, EVERY later session on the host captured CPU frames until the process was restarted —
|
||||
/// including sessions against a completely different compositor and node, which had never failed
|
||||
/// at anything.
|
||||
///
|
||||
/// * **Import failures stay sticky.** A driver that will not take what the compositor allocates
|
||||
/// refuses identically on every retry, and the encode-stall recovery above cannot tell that from
|
||||
/// a transient — it rebuilt the same failing encoder five times and then ended the session, on
|
||||
/// every connection, forever. That is what this latch was born to stop, and it must keep
|
||||
/// stopping it.
|
||||
/// * **Negotiation timeouts get a retry budget** ([`RAW_DMABUF_NEGOTIATION_LATCH`]).
|
||||
/// * **Both are keyed to a capture identity.** A new node id — a fresh virtual output, the
|
||||
/// Bazzite Gaming↔Desktop switch, a compositor restart — is a genuinely different question, so
|
||||
/// it earns a fresh dmabuf attempt instead of inheriting a verdict about something else.
|
||||
///
|
||||
/// Atomics rather than a lock because [`note_import_ok`](Self::note_import_ok) is on the per-frame
|
||||
/// import path; everything else here runs at pipeline build or on failure.
|
||||
#[derive(Debug)]
|
||||
pub struct RawDmabufLatch {
|
||||
import_streak: AtomicU32,
|
||||
import_latched: AtomicBool,
|
||||
negotiation_streak: AtomicU32,
|
||||
negotiation_latched: AtomicBool,
|
||||
/// The capture identity the counters above describe. `u64::MAX` = nothing observed yet (a real
|
||||
/// identity is a node id, so it can never collide with the sentinel).
|
||||
identity: AtomicU64,
|
||||
}
|
||||
|
||||
/// Nothing observed yet — distinct from any real capture identity.
|
||||
const NO_IDENTITY: u64 = u64::MAX;
|
||||
|
||||
impl RawDmabufLatch {
|
||||
pub const fn new() -> Self {
|
||||
RawDmabufLatch {
|
||||
import_streak: AtomicU32::new(0),
|
||||
import_latched: AtomicBool::new(false),
|
||||
negotiation_streak: AtomicU32::new(0),
|
||||
negotiation_latched: AtomicBool::new(false),
|
||||
identity: AtomicU64::new(NO_IDENTITY),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the raw-dmabuf passthrough is currently off, for either cause.
|
||||
pub fn disabled(&self) -> bool {
|
||||
self.import_latched.load(Ordering::Relaxed)
|
||||
|| self.negotiation_latched.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Tell the latch which capture is about to be built. A DIFFERENT capture from the one the
|
||||
/// current verdict was formed against clears every counter and both latches, so the new
|
||||
/// pipeline earns a fresh dmabuf attempt.
|
||||
///
|
||||
/// Returns `true` only when that clear actually **re-armed something** — i.e. the identity
|
||||
/// changed *and* a latch was set. Deliberately not "the identity changed": every session on a
|
||||
/// fresh virtual output changes it, and a caller that logged on that would print a re-arm line
|
||||
/// on every healthy session open, which is noise. `true` means "this capture would have been
|
||||
/// forced to CPU by an earlier capture's verdict, and no longer is".
|
||||
///
|
||||
/// Call this BEFORE reading [`disabled`](Self::disabled) for a negotiation decision, or the
|
||||
/// decision is made against the previous capture's verdict.
|
||||
pub fn observe_capture(&self, identity: u64) -> bool {
|
||||
if self.identity.swap(identity, Ordering::Relaxed) == identity {
|
||||
return false;
|
||||
}
|
||||
let was_latched = self.disabled();
|
||||
self.import_streak.store(0, Ordering::Relaxed);
|
||||
self.import_latched.store(false, Ordering::Relaxed);
|
||||
self.negotiation_streak.store(0, Ordering::Relaxed);
|
||||
self.negotiation_latched.store(false, Ordering::Relaxed);
|
||||
was_latched
|
||||
}
|
||||
|
||||
/// Record an encoder-side raw-dmabuf import failure. Returns `true` if this failure is the one
|
||||
/// that latched the passthrough off.
|
||||
pub fn note_import_failure(&self) -> Option<u32> {
|
||||
let streak = self.import_streak.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
(streak >= RAW_DMABUF_FAILURE_LATCH && !self.import_latched.swap(true, Ordering::Relaxed))
|
||||
.then_some(streak)
|
||||
}
|
||||
|
||||
/// Record a raw dmabuf that imported and encoded — resets the failure streak. The per-frame
|
||||
/// hot path, hence a single relaxed store.
|
||||
///
|
||||
/// Deliberately does NOT clear `import_latched`: once the latch fires, capture has already
|
||||
/// moved to CPU frames, so there are no more dmabuf imports to succeed. Only a new capture
|
||||
/// identity clears it.
|
||||
pub fn note_import_ok(&self) {
|
||||
self.import_streak.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record a capture rebuild whose dmabuf-only offer never negotiated. Returns `Some(streak)`
|
||||
/// if this is the failure that latched the passthrough off, `None` while retries remain.
|
||||
pub fn note_negotiation_timeout(&self) -> Option<u32> {
|
||||
let streak = self.negotiation_streak.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
(streak >= RAW_DMABUF_NEGOTIATION_LATCH
|
||||
&& !self.negotiation_latched.swap(true, Ordering::Relaxed))
|
||||
.then_some(streak)
|
||||
}
|
||||
|
||||
/// Record a capture whose dmabuf offer DID negotiate — the retry budget is per consecutive
|
||||
/// run of failures, so a success spends none of it.
|
||||
pub fn note_negotiation_ok(&self) {
|
||||
self.negotiation_streak.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Diagnostic for the session-open line: which cause (if any) currently holds it off.
|
||||
pub fn state(&self) -> &'static str {
|
||||
match (
|
||||
self.import_latched.load(Ordering::Relaxed),
|
||||
self.negotiation_latched.load(Ordering::Relaxed),
|
||||
) {
|
||||
(true, true) => "latched: encoder-import + negotiation",
|
||||
(true, false) => "latched: encoder-import failures",
|
||||
(false, true) => "latched: negotiation timeouts",
|
||||
(false, false) => "live",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RawDmabufLatch {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
static RAW_DMABUF: RawDmabufLatch = RawDmabufLatch::new();
|
||||
|
||||
/// Record an encoder-side raw-dmabuf import failure. Latches the passthrough off after
|
||||
/// `RAW_DMABUF_FAILURE_LATCH` consecutive failures, until the capture identity changes.
|
||||
pub fn note_raw_dmabuf_import_failure(reason: &str) {
|
||||
let streak = RAW_DMABUF_FAILURE_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if streak >= RAW_DMABUF_FAILURE_LATCH && !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
|
||||
if let Some(streak) = RAW_DMABUF.note_import_failure() {
|
||||
tracing::error!(
|
||||
streak,
|
||||
reason,
|
||||
"zero-copy raw-dmabuf passthrough disabled for this host process: the encoder failed \
|
||||
to import the compositor's dmabuf {streak} times in a row — captures fall back to the \
|
||||
CPU path (slower, but this host could not stream at all otherwise)"
|
||||
"zero-copy raw-dmabuf passthrough disabled: the encoder failed to import the \
|
||||
compositor's dmabuf {streak} times in a row — captures fall back to the CPU path \
|
||||
(slower, but this host could not stream at all otherwise). A new capture (different \
|
||||
node / compositor) clears this."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a raw dmabuf that imported and encoded — resets the failure streak.
|
||||
pub fn note_raw_dmabuf_import_ok() {
|
||||
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
|
||||
RAW_DMABUF.note_import_ok();
|
||||
}
|
||||
|
||||
/// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the
|
||||
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One
|
||||
/// timeout is conclusive for this offer (a compositor that cannot allocate the requested
|
||||
/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to
|
||||
/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the
|
||||
/// same 10 s timeout on every reconnect.
|
||||
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak.
|
||||
///
|
||||
/// Unlike the import streak this gets a retry budget: the offer can time out because the
|
||||
/// compositor was mid-restart rather than because it will never accept, and the old behaviour
|
||||
/// (one timeout = CPU capture for the rest of the host's life, for every compositor and every
|
||||
/// node) turned a transient into a permanent downgrade nobody could see.
|
||||
///
|
||||
/// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so
|
||||
/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the
|
||||
/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is
|
||||
/// untouched.
|
||||
/// disabled ALL zero-copy host-wide — see [`enabled`]. It gates only the raw-passthrough decision,
|
||||
/// so the EGL→CUDA importer that a later NVENC session builds is untouched.
|
||||
pub fn note_raw_dmabuf_negotiation_failed() {
|
||||
if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \
|
||||
accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \
|
||||
instead of repeating that timeout (the EGL→CUDA import path is NOT affected)"
|
||||
);
|
||||
match RAW_DMABUF.note_negotiation_timeout() {
|
||||
Some(streak) => tracing::warn!(
|
||||
streak,
|
||||
"zero-copy raw-dmabuf passthrough disabled: the compositor did not accept the \
|
||||
dmabuf-only capture offer {streak} builds in a row, so later captures negotiate the \
|
||||
CPU path instead of repeating that timeout (the EGL→CUDA import path is NOT \
|
||||
affected). A new capture (different node / compositor) clears this."
|
||||
),
|
||||
None => tracing::warn!(
|
||||
"the compositor did not accept the dmabuf-only capture offer — retrying dmabuf on the \
|
||||
next capture build before giving up on it"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||
/// [`note_raw_dmabuf_import_failure`]).
|
||||
/// Record a capture whose dmabuf offer negotiated — spends none of the retry budget.
|
||||
pub fn note_raw_dmabuf_negotiation_ok() {
|
||||
RAW_DMABUF.note_negotiation_ok();
|
||||
}
|
||||
|
||||
/// Tell the latch which capture is about to be built, so a verdict formed against a DIFFERENT
|
||||
/// compositor/node is not inherited. Returns `true` if a latch was cleared by the change.
|
||||
pub fn note_raw_dmabuf_capture(identity: u64) -> bool {
|
||||
let cleared = RAW_DMABUF.observe_capture(identity);
|
||||
if cleared {
|
||||
tracing::info!(
|
||||
identity,
|
||||
"zero-copy raw-dmabuf passthrough re-armed: this is a different capture from the one \
|
||||
that failed, so it gets a fresh dmabuf attempt"
|
||||
);
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
/// True while either cause holds the raw-dmabuf passthrough off (see [`RawDmabufLatch`]).
|
||||
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
|
||||
RAW_DMABUF.disabled()
|
||||
}
|
||||
|
||||
/// Which cause holds the passthrough off, for the session-open diagnostic line.
|
||||
pub fn raw_dmabuf_latch_state() -> &'static str {
|
||||
RAW_DMABUF.state()
|
||||
}
|
||||
|
||||
/// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU
|
||||
@@ -564,4 +731,131 @@ mod tests {
|
||||
note_gpu_import_death(); // third consecutive death
|
||||
assert!(gpu_import_disabled());
|
||||
}
|
||||
|
||||
// ---- PW3: the raw-dmabuf latch's two lifetimes ------------------------------------------
|
||||
//
|
||||
// Against a LOCAL `RawDmabufLatch`, never the process-wide static: these assertions are about
|
||||
// the state machine, and sharing one global across a test binary's threads is how a latch test
|
||||
// becomes order-dependent.
|
||||
|
||||
/// The expensive cause stays sticky. A driver that cannot import what this compositor
|
||||
/// allocates refuses identically every time, and the encode-stall recovery cannot tell that
|
||||
/// from a transient — this latch is what stops it rebuilding the same doomed encoder forever.
|
||||
#[test]
|
||||
fn import_failures_latch_and_stay_latched() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert!(!l.disabled());
|
||||
assert_eq!(l.note_import_failure(), None); // 1
|
||||
assert_eq!(l.note_import_failure(), None); // 2
|
||||
assert!(!l.disabled(), "must not latch before the streak completes");
|
||||
assert_eq!(l.note_import_failure(), Some(3));
|
||||
assert!(l.disabled());
|
||||
// Only the FIRST crossing reports, so the error line cannot repeat per frame.
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
// A success resets the streak but must NOT unlatch: once capture moved to CPU frames there
|
||||
// are no more dmabuf imports, so an "ok" here would be about something else entirely.
|
||||
l.note_import_ok();
|
||||
assert!(l.disabled());
|
||||
}
|
||||
|
||||
/// A run of failures broken by a success spends none of the budget — the streak is
|
||||
/// consecutive-only, which is what makes an occasional failure survivable.
|
||||
#[test]
|
||||
fn a_success_breaks_the_import_streak() {
|
||||
let l = RawDmabufLatch::new();
|
||||
l.note_import_failure();
|
||||
l.note_import_failure();
|
||||
l.note_import_ok();
|
||||
assert_eq!(l.note_import_failure(), None, "streak restarted at 1");
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
assert!(!l.disabled());
|
||||
assert_eq!(l.note_import_failure(), Some(3));
|
||||
}
|
||||
|
||||
/// The cheap cause gets a retry. This is the behaviour change PW3 exists for: one timeout used
|
||||
/// to mean CPU capture for the rest of the host's life, on every compositor and every node.
|
||||
#[test]
|
||||
fn a_negotiation_timeout_is_retried_before_it_latches() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert_eq!(l.note_negotiation_timeout(), None, "first one retries");
|
||||
assert!(
|
||||
!l.disabled(),
|
||||
"the next capture build must still be allowed to try dmabuf"
|
||||
);
|
||||
assert_eq!(l.note_negotiation_timeout(), Some(2));
|
||||
assert!(l.disabled());
|
||||
assert_eq!(l.note_negotiation_timeout(), None, "reports once");
|
||||
}
|
||||
|
||||
/// A capture that negotiates credits the budget back, so a compositor that fails once and then
|
||||
/// works never accumulates its way to a latch across an evening of reconnects.
|
||||
#[test]
|
||||
fn a_negotiated_capture_credits_the_retry_budget() {
|
||||
let l = RawDmabufLatch::new();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(l.note_negotiation_timeout(), None);
|
||||
l.note_negotiation_ok();
|
||||
}
|
||||
assert!(!l.disabled());
|
||||
}
|
||||
|
||||
/// A different capture is a different question. New node id (fresh virtual output, compositor
|
||||
/// restart, the Bazzite Gaming↔Desktop switch) clears BOTH causes — the same capture does not.
|
||||
#[test]
|
||||
fn a_new_capture_identity_clears_the_latch_and_the_same_one_does_not() {
|
||||
let l = RawDmabufLatch::new();
|
||||
// Nothing is latched yet, so observing a new capture re-arms NOTHING — that is what the
|
||||
// return value means, and it is why a healthy session open logs no re-arm line.
|
||||
assert!(
|
||||
!l.observe_capture(7),
|
||||
"nothing was latched, nothing re-armed"
|
||||
);
|
||||
assert!(!l.observe_capture(7), "same capture, no clear");
|
||||
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
|
||||
l.note_import_failure();
|
||||
}
|
||||
assert!(l.disabled());
|
||||
assert!(
|
||||
!l.observe_capture(7),
|
||||
"the SAME capture must keep its verdict — this is the 10s-stall hazard the latch exists for"
|
||||
);
|
||||
assert!(l.disabled());
|
||||
assert!(l.observe_capture(9), "a different node re-arms it");
|
||||
assert!(!l.disabled());
|
||||
// ...and the streaks reset with it, so the fresh attempt gets a full budget.
|
||||
assert_eq!(l.note_import_failure(), None);
|
||||
}
|
||||
|
||||
/// The negotiation latch is keyed the same way — a compositor restart must not inherit the
|
||||
/// previous one's timeout verdict.
|
||||
#[test]
|
||||
fn a_new_capture_identity_clears_the_negotiation_latch_too() {
|
||||
let l = RawDmabufLatch::new();
|
||||
l.observe_capture(1);
|
||||
l.note_negotiation_timeout();
|
||||
l.note_negotiation_timeout();
|
||||
assert!(l.disabled());
|
||||
assert!(l.observe_capture(2));
|
||||
assert!(!l.disabled());
|
||||
}
|
||||
|
||||
/// The session-open line has to name WHICH cause holds it off — "cpu because nothing here
|
||||
/// does dmabuf" and "cpu because something failed earlier" are different bugs.
|
||||
#[test]
|
||||
fn latch_state_names_the_cause() {
|
||||
let l = RawDmabufLatch::new();
|
||||
assert_eq!(l.state(), "live");
|
||||
l.note_negotiation_timeout();
|
||||
l.note_negotiation_timeout();
|
||||
assert_eq!(l.state(), "latched: negotiation timeouts");
|
||||
let l = RawDmabufLatch::new();
|
||||
for _ in 0..RAW_DMABUF_FAILURE_LATCH {
|
||||
l.note_import_failure();
|
||||
}
|
||||
assert_eq!(l.state(), "latched: encoder-import failures");
|
||||
for _ in 0..RAW_DMABUF_NEGOTIATION_LATCH {
|
||||
l.note_negotiation_timeout();
|
||||
}
|
||||
assert_eq!(l.state(), "latched: encoder-import + negotiation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,18 @@ impl FrameChannel {
|
||||
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
|
||||
/// instead — the skipped ones are already superseded and decode independently, so showing
|
||||
/// them only adds latency.
|
||||
///
|
||||
/// ⚠ **The all-intra drain counts QUEUE ENTRIES and assumes one entry == one AU.** That holds
|
||||
/// today only because slice-progressive delivery is refused on PyroWave
|
||||
/// (`client/pump/handshake.rs`; see [`crate::session::Session::set_deliver_frame_parts`]).
|
||||
/// Turn parts on for an all-intra stream and one AU pushes several entries, at which point
|
||||
/// `len > 1` no longer means "the consumer is behind": this fires mid-AU, hands back a SUFFIX
|
||||
/// and `clear()`s that AU's own prefixes — a headerless frame, every frame. Anyone making the
|
||||
/// two composable must skip whole SUPERSEDED AUs (drop up to the newest entry whose
|
||||
/// `part.first` is set, never split an AU), give `push`'s `FRAME_QUEUE_HARD_CAP` eviction the
|
||||
/// same rule, and count `skipped_total` in AUs. Host-side streamed AUs
|
||||
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) are NOT affected — they still arrive as one
|
||||
/// completed `Frame` per AU.
|
||||
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
|
||||
let mut st = self.inner.lock().unwrap();
|
||||
if st.q.is_empty() && !st.closed {
|
||||
|
||||
@@ -229,7 +229,10 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
}
|
||||
// Slice-progressive delivery (the embedder's opt-in): AU prefixes hand up as
|
||||
// `Frame::part` pieces while the tail is still on the wire. Never on PyroWave — its
|
||||
// all-intra frame channel drains newest-wins, which assumes whole AUs.
|
||||
// all-intra frame channel drains newest-wins per QUEUE ENTRY, so parts of one AU read as
|
||||
// separate AUs and the drain shreds the AU it is mid-way through (`FrameChannel::pop`
|
||||
// spells out the mechanism and what a fix would take). Unrelated to the host's streamed-AU
|
||||
// wire (`VIDEO_CAP_STREAMED_AU`), which still completes one whole `Frame` per AU.
|
||||
if args.frame_parts && welcome.codec != crate::quic::CODEC_PYROWAVE {
|
||||
session.set_deliver_frame_parts(true);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,49 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
/// Endpoint config for the CLIENT endpoint — the half of the jumbo opt-in that lives on the
|
||||
/// receiving side, and without which the whole jumbo leg is unreachable.
|
||||
///
|
||||
/// `EndpointConfig::max_udp_payload_size` is the QUIC transport parameter this endpoint
|
||||
/// advertises: "the largest UDP payload I accept". quinn defaults it to **1472** (a 1500-byte
|
||||
/// Ethernet MTU), and a peer's MTU-discovery search is upper-bounded by
|
||||
/// `min(MtuDiscoveryConfig::upper_bound, the value the OTHER side advertised)`
|
||||
/// (`quinn_proto::connection::mtud::SearchState::new`). So raising the host's probe ceiling
|
||||
/// alone — which is all [`stream_transport_idle`] did — can never make a host's discovery
|
||||
/// settle above 1472: the *client's* default advertisement caps it, and the host's
|
||||
/// settled-at-jumbo proof (`native/wire_mtu.rs`, both the mid-session grow and the
|
||||
/// session-start one) could never fire. This raises the advertisement to the sealed jumbo
|
||||
/// datagram size so the proof is obtainable at all.
|
||||
///
|
||||
/// Gated on the SAME operator opt-in as the probe ceiling ([`crate::config::jumbo_wire_mtu`],
|
||||
/// i.e. `PUNKTFUNK_JUMBO=1` / `PUNKTFUNK_WIRE_MTU` > 1500) because it is not free: quinn sizes
|
||||
/// its endpoint receive buffer as `max_udp_payload_size × max_receive_segments × BATCH_SIZE`,
|
||||
/// which on a GRO-capable Linux/Android client is 64 × 32 segments — ~2.9 MiB at the 1472
|
||||
/// default, ~18 MiB at jumbo. A jumbo LAN is a deliberate deployment; every other client keeps
|
||||
/// today's buffer to the byte. Without the opt-in this returns the stock config, so the
|
||||
/// advertisement, the wire, and the memory are all unchanged.
|
||||
fn endpoint_config() -> quinn::EndpointConfig {
|
||||
let mut cfg = quinn::EndpointConfig::default();
|
||||
if let Some(mtu) = crate::config::jumbo_wire_mtu() {
|
||||
// Derived exactly like the probe ceiling above (IPv4 overhead — a v6 peer's sealed
|
||||
// target is smaller, so this covers it), and clamped into quinn's accepted range.
|
||||
let shard = crate::config::jumbo_shard_payload_for(
|
||||
mtu,
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
);
|
||||
let accept = crate::config::sealed_datagram_bytes(shard).clamp(1200, 65_527) as u16;
|
||||
if cfg.max_udp_payload_size(accept).is_ok() {
|
||||
tracing::info!(
|
||||
max_udp_payload_size = accept,
|
||||
wire_mtu = mtu,
|
||||
"jumbo opt-in: this endpoint advertises a jumbo QUIC receive ceiling, so the \
|
||||
peer's MTU discovery can prove a jumbo path (it is capped by this value)"
|
||||
);
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
||||
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
||||
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
@@ -238,7 +281,15 @@ pub fn client_pinned_with_identity(
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
|
||||
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
|
||||
// `Endpoint::client` hardcodes `EndpointConfig::default()`, whose 1472-byte
|
||||
// `max_udp_payload_size` caps the HOST's MTU discovery (see `endpoint_config`), so the
|
||||
// endpoint is built by hand to carry the jumbo opt-in. Same bind as before
|
||||
// (`0.0.0.0:0`, v4 — no dual-stack flag to reproduce) and the same default runtime.
|
||||
let socket = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let runtime = quinn::default_runtime()
|
||||
.ok_or_else(|| anyhow_result::Error::msg("no async runtime found".into()))?;
|
||||
let mut ep = quinn::Endpoint::new(endpoint_config(), None, socket, runtime)?;
|
||||
ep.set_default_client_config(client_cfg);
|
||||
Ok(ep)
|
||||
})();
|
||||
@@ -348,4 +399,80 @@ mod tests {
|
||||
let _ = super::stream_transport_idle(std::time::Duration::MAX);
|
||||
let _ = super::stream_transport_idle(std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
/// Where a connection's MTU discovery is allowed to climb to, measured rather than argued
|
||||
/// (PW7a). Loopback's own MTU is 64 KiB, so the ONLY thing that can stop the search here is
|
||||
/// configuration — which makes this a clean instrument for the two ceilings:
|
||||
///
|
||||
/// * **leg A** — server opted in, client NOT: the search stalls at the client's default
|
||||
/// `max_udp_payload_size` advertisement (1472) no matter how high the server's probe
|
||||
/// ceiling is. This is why the shipped jumbo grow could never fire: `wire_mtu.rs` waits
|
||||
/// for a settle at the sealed jumbo size and the peer's transport parameter forbids it.
|
||||
/// * **leg B** — both opted in: the search reaches the sealed jumbo datagram, and the
|
||||
/// elapsed time is what the `Welcome`'s bounded proof-wait has to cover.
|
||||
///
|
||||
/// `#[ignore]`d: it sets process-wide env (each endpoint reads the opt-in at construction,
|
||||
/// which is exactly how the two legs are built) and spends seconds of wall clock.
|
||||
/// Run it alone: `cargo test -p punktfunk-core --features quic mtu_discovery -- --ignored
|
||||
/// --nocapture --test-threads=1`.
|
||||
#[tokio::test]
|
||||
#[ignore = "measurement: sets process env and takes ~15 s of wall clock"]
|
||||
async fn mtu_discovery_climbs_only_as_high_as_the_peer_advertises() {
|
||||
async fn climb(server_jumbo: bool, client_jumbo: bool) -> (u16, u128) {
|
||||
let set = |on: bool| {
|
||||
if on {
|
||||
std::env::set_var("PUNKTFUNK_JUMBO", "1");
|
||||
} else {
|
||||
std::env::remove_var("PUNKTFUNK_JUMBO");
|
||||
}
|
||||
};
|
||||
set(server_jumbo);
|
||||
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
set(client_jumbo);
|
||||
let client = endpoint::client_insecure().unwrap();
|
||||
set(false);
|
||||
let accept = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.expect("incoming");
|
||||
let conn = incoming.await.expect("host side connects");
|
||||
(server, conn)
|
||||
});
|
||||
let client_conn = client.connect(addr, "punktfunk").unwrap().await.unwrap();
|
||||
let (_server_ep, host_conn) = accept.await.unwrap();
|
||||
// A stream write gives the driver something to transmit, which is what starts the
|
||||
// search (probes ride `poll_transmit`); after that each probe's ack drives the next.
|
||||
let mut s = host_conn.open_uni().await.unwrap();
|
||||
s.write_all(b"go").await.unwrap();
|
||||
let want = crate::config::sealed_datagram_bytes(crate::config::jumbo_shard_payload_for(
|
||||
9000,
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
)) as u16;
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut mtu = host_conn.stats().path.current_mtu;
|
||||
while t0.elapsed() < std::time::Duration::from_secs(6) && mtu < want {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
mtu = host_conn.stats().path.current_mtu;
|
||||
}
|
||||
let elapsed = t0.elapsed().as_millis();
|
||||
drop(client_conn);
|
||||
drop(client);
|
||||
(mtu, elapsed)
|
||||
}
|
||||
|
||||
let (capped, _) = climb(true, false).await;
|
||||
println!("leg A (server opted in, client not): settled at {capped} B UDP payload");
|
||||
assert_eq!(
|
||||
capped, 1472,
|
||||
"a peer that advertises the stock max_udp_payload_size caps the search at 1472 — \
|
||||
the whole point of raising it on the client endpoint"
|
||||
);
|
||||
|
||||
let (grown, ms) = climb(true, true).await;
|
||||
println!("leg B (both opted in): reached {grown} B UDP payload in {ms} ms");
|
||||
assert!(
|
||||
grown >= 8972,
|
||||
"both sides opted in, loopback MTU is 64 KiB — discovery should reach the sealed \
|
||||
jumbo datagram, got {grown}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,8 +677,21 @@ impl Session {
|
||||
/// [`Frame::part`]` = Some` while the rest is still on the wire, instead of one whole-AU
|
||||
/// delivery (the slice-progressive decode path — [`crate::packet::USER_FLAG_SLICE_STREAM`]).
|
||||
/// With it on, EVERY video frame delivery carries `part: Some` (a frame with no early
|
||||
/// parts arrives as the degenerate `{offset: 0, first, last}` whole). Do not combine with
|
||||
/// an all-intra (PyroWave) stream: its newest-wins draining assumes whole AUs.
|
||||
/// parts arrives as the degenerate `{offset: 0, first, last}` whole).
|
||||
///
|
||||
/// **Do not combine with an all-intra (PyroWave) stream**, and the reason is sharper than
|
||||
/// "newest-wins draining assumes whole AUs" (2026-08-08, PW6): the drain
|
||||
/// (`client::frame_channel::FrameChannel::pop`) counts QUEUE ENTRIES and takes one entry to be
|
||||
/// one AU. With parts on, a single AU pushes K entries, so `len > 1` stops meaning "the consumer
|
||||
/// is behind" — the drain fires mid-AU, returns the newest entry (a SUFFIX) and clears that
|
||||
/// same AU's prefixes. For PyroWave that is unrecoverable rather than lossy: the sequence
|
||||
/// header lives in window 0 of every AU, so every frame would arrive headerless. Making the
|
||||
/// two composable means teaching the drain to skip whole superseded AUs (never to split one)
|
||||
/// — see the PW6 section of `design/linux-host-performance-wave2-pyrowave.md`.
|
||||
///
|
||||
/// Note this is a DIFFERENT axis from the host's streamed-AU wire
|
||||
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): a streamed AU still completes as ONE `Frame`
|
||||
/// here, so it is unaffected by any of the above.
|
||||
pub fn set_deliver_frame_parts(&mut self, on: bool) {
|
||||
self.reassembler.set_deliver_parts(on);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ mod hidden;
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod lutris;
|
||||
mod plugin_launch;
|
||||
mod scanners;
|
||||
mod steam;
|
||||
#[cfg(windows)]
|
||||
@@ -51,6 +52,7 @@ pub use hidden::*;
|
||||
pub use launch::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use lutris::*;
|
||||
pub use plugin_launch::*;
|
||||
pub use scanners::*;
|
||||
pub use steam::*;
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -476,6 +476,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
|
||||
));
|
||||
}
|
||||
// `plugin`: the value is an opaque key in the OWNING plugin's own namespace, handed back
|
||||
// to it at launch time (see `library::ask_plugin_launch`). The host never parses it, so
|
||||
// the only checks are the ones that keep it loggable and bounded.
|
||||
if launch.kind == "plugin" && !valid_plugin_entry_key(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `plugin` must be 1–512 chars with no \
|
||||
control characters"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(marker) = &e.detect.env_marker {
|
||||
if !valid_env_key(&marker.key) {
|
||||
|
||||
@@ -52,7 +52,9 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||
{
|
||||
// Linux runs the command itself, so a title without one has nothing to launch — same answer
|
||||
// (and same warning path) as before this resolution existed.
|
||||
let command = entry.launch.as_ref().and_then(command_for)?;
|
||||
let command = plugin_recipe(&entry)
|
||||
.map(|l| l.command)
|
||||
.or_else(|| entry.launch.as_ref().and_then(command_for))?;
|
||||
Some(LaunchTarget {
|
||||
game,
|
||||
launcher: entry.role == GameRole::Launcher,
|
||||
@@ -74,9 +76,66 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The recipe for a `plugin`-kind entry, asked of the plugin that owns it. `None` for every other
|
||||
/// kind (without doing any I/O), so both per-OS resolvers can simply try this first.
|
||||
///
|
||||
/// This lives beside [`resolve_launch`] / [`launch_title`] rather than inside `command_for` /
|
||||
/// `windows_launch_for` because it needs the entry's **`provider`** — and that field is the whole
|
||||
/// authorization story. `provider` is stamped by the host from the reconcile URL
|
||||
/// (`PUT /library/provider/{provider}`), never taken from the payload, so it is what decides which
|
||||
/// plugin gets asked. A plugin that plants an entry under someone else's provider only causes that
|
||||
/// *other* plugin to be asked about a key it never published — which is a 404, not a launch.
|
||||
///
|
||||
/// **Blocking**: see [`ask_plugin_launch`]. `resolve_launch`'s async callers hop through
|
||||
/// `spawn_blocking`; the handshake probe uses [`launch_is_resolvable`], which never asks.
|
||||
fn plugin_recipe(entry: &GameEntry) -> Option<PluginLaunch> {
|
||||
let spec = entry.launch.as_ref()?;
|
||||
if spec.kind != "plugin" {
|
||||
return None;
|
||||
}
|
||||
let Some(provider) = entry.provider.as_deref() else {
|
||||
// Only a provider reconcile can author this kind, so this is unreachable short of a
|
||||
// hand-edited library.json — say so rather than silently doing nothing.
|
||||
tracing::warn!(
|
||||
id = %entry.id,
|
||||
"plugin launch: entry carries no provider, so no plugin can answer for it"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
ask_plugin_launch(provider, &spec.value)
|
||||
}
|
||||
|
||||
/// Whether `id` will actually launch something — **without asking a plugin**.
|
||||
///
|
||||
/// The handshake needs this one bit to decide dedicated-session routing, and it runs on the async
|
||||
/// path, so it must not make a blocking call out to a plugin. For a `plugin`-kind entry the cheap
|
||||
/// answer is "a live plugin is registered under its provider, and the key is well formed"; if that
|
||||
/// plugin later refuses the ask, the launch fails the same way any unresolvable entry does and the
|
||||
/// player is left on the session.
|
||||
#[cfg(not(windows))]
|
||||
pub fn launch_is_resolvable(id: &str) -> bool {
|
||||
let Some(entry) = all_games().into_iter().find(|g| g.id == id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(spec) = entry.launch.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if spec.kind == "plugin" {
|
||||
return valid_plugin_entry_key(&spec.value)
|
||||
&& entry
|
||||
.provider
|
||||
.as_deref()
|
||||
.is_some_and(|p| crate::mgmt::ui_credential(p).is_some());
|
||||
}
|
||||
command_for(spec).is_some()
|
||||
}
|
||||
|
||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
|
||||
///
|
||||
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
|
||||
/// resolved by [`plugin_recipe`] before this is reached.
|
||||
///
|
||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||
@@ -126,17 +185,24 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
/// desktop and grabs foreground.
|
||||
#[cfg(windows)]
|
||||
pub fn launch_title(id: &str) -> Result<()> {
|
||||
let spec = all_games()
|
||||
let entry = all_games()
|
||||
.into_iter()
|
||||
.find(|g| g.id == id)
|
||||
.and_then(|g| g.launch)
|
||||
.filter(|g| g.launch.is_some())
|
||||
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
||||
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
||||
spec.kind
|
||||
)
|
||||
})?;
|
||||
let spec = entry.launch.clone().expect("filtered to Some above");
|
||||
// A `plugin` entry's recipe comes from the plugin that owns it, and arrives in the same
|
||||
// (command line, working dir) shape this path already spawns. `windows_launch_for` has no arm
|
||||
// for the kind, so a failed ask falls through to the "no recipe" error below.
|
||||
let (cmdline, workdir) = plugin_recipe(&entry)
|
||||
.map(|l| (l.command, l.cwd))
|
||||
.or_else(|| windows_launch_for(&spec))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
||||
spec.kind
|
||||
)
|
||||
})?;
|
||||
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
||||
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
||||
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
||||
@@ -148,6 +214,9 @@ pub fn launch_title(id: &str) -> Result<()> {
|
||||
///
|
||||
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
||||
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
||||
///
|
||||
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
|
||||
/// resolved by [`plugin_recipe`] before this is reached.
|
||||
#[cfg(windows)]
|
||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
||||
match spec.kind.as_str() {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! The `plugin` launch kind's transport: ask a library plugin what to run for one of **its own**
|
||||
//! entries, at launch time, over the loopback UI surface it already registered.
|
||||
//!
|
||||
//! ## Why the host asks instead of storing a command
|
||||
//!
|
||||
//! A ROM tile is `<emulator> <args> <rom>` — an operator-configured command line, and the one shape
|
||||
//! [`super::privileged_field`] refuses from the plugin lane (2026-08-05 review H-1). The Playnite
|
||||
//! plugin hit the same wall and was rescued with a typed `playnite` kind the host resolves itself
|
||||
//! (see `command_for`), but that only works because a Playnite launch is a fixed URI scheme. There
|
||||
//! is no fixed scheme for "some emulator the operator installed, with the core and flags they chose"
|
||||
//! — the knowledge lives in the plugin, and it is the plugin that owns the hardened quoting seam for
|
||||
//! it (ROM filenames are untrusted input).
|
||||
//!
|
||||
//! So the entry carries an **opaque key** and nothing executable, and the command is fetched from
|
||||
//! the owning plugin at the moment of an actual launch. What that buys over letting the plugin write
|
||||
//! `kind = "command"` straight into the library:
|
||||
//!
|
||||
//! * **A stolen plugin token is no longer command execution.** Planting an entry is not enough — the
|
||||
//! host asks the *live registered plugin* what to run, authenticated with the per-boot secret only
|
||||
//! that process knows. A plugin asked about an entry it never published answers 404 (this is why
|
||||
//! the ask names the entry rather than trusting the payload), so a forged entry launches nothing.
|
||||
//! * **Nothing executable is ever persisted or served.** No command lands in `library.json`, and
|
||||
//! `GET /library` has none to redact for a paired client.
|
||||
//! * **No stale recipes.** The same reasoning as the `xbox` kind resolving its AUMID at launch time:
|
||||
//! an emulator that moved, or a config the operator has since edited, is picked up on the next
|
||||
//! launch instead of leaving an unlaunchable tile behind.
|
||||
//!
|
||||
//! The host still *runs* the command, because only the host can put the process where the stream can
|
||||
//! see it: on Linux the line is either gamescope's own argv (a bare-spawn session nests it) or a
|
||||
//! spawn carrying the session's compositor env, and the returned child is what
|
||||
//! `design/session-game-lifetime.md` tracks to know the game exited. A plugin spawning the emulator
|
||||
//! itself would land it outside the captured session and outside that lifetime.
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against
|
||||
/// state it already holds, so this is generous for a healthy plugin and short enough that a wedged
|
||||
/// one cannot hold a launch — or, on the GameStream plane, the data-plane thread that calls this —
|
||||
/// for longer than a player would keep staring at a tile that did nothing.
|
||||
const ASK_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A command LINE, not a script. Generous for `flatpak run … --core=… "/very/long/rom path"`,
|
||||
/// bounded so a malformed answer cannot land a megabyte in the logs or in a shell argument.
|
||||
const MAX_COMMAND: usize = 4096;
|
||||
|
||||
/// Cap the whole response body — the shape is two short strings.
|
||||
const MAX_BODY: usize = 64 * 1024;
|
||||
|
||||
/// What a plugin answered: the command line to run, and optionally the directory to run it in
|
||||
/// (emulators that resolve cores or configs relative to their install dir need one).
|
||||
pub struct PluginLaunch {
|
||||
pub command: String,
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The wire shape of `POST /__launch`'s response.
|
||||
#[derive(Deserialize)]
|
||||
struct LaunchReply {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
/// The opaque per-entry key a `plugin` launch carries. It is echoed to the owning plugin as JSON and
|
||||
/// lands in log lines, so bound it and keep control characters out; everything else is the plugin's
|
||||
/// own namespace (rom-manager uses its `<platform>/<relpath>` external id).
|
||||
pub fn valid_plugin_entry_key(v: &str) -> bool {
|
||||
!v.is_empty() && v.len() <= 512 && !v.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Ask `plugin` what to run for its entry `key`.
|
||||
///
|
||||
/// `None` — the plugin is not registered/live, has no UI surface, disowns the entry, or answered
|
||||
/// something unusable. Every arm logs, because from a player's seat all of them look like "the tile
|
||||
/// did nothing", and the difference is exactly what an operator needs to fix it.
|
||||
///
|
||||
/// **Blocking** (`ureq`, the host's existing off-runtime HTTP client): callers run on a blocking
|
||||
/// thread. `resolve_launch`'s async callers hop through `spawn_blocking`, and the handshake's
|
||||
/// "is this launchable at all" probe uses [`super::launch_is_resolvable`], which never asks.
|
||||
pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
|
||||
if !valid_plugin_entry_key(key) {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
"plugin launch: entry key failed validation — ignoring"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let Some(cred) = crate::mgmt::ui_credential(plugin) else {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: no live plugin registered under that provider id (is it running?) — \
|
||||
nothing to launch"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build();
|
||||
// Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The
|
||||
// registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial
|
||||
// this machine.
|
||||
// `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json`
|
||||
// feature, and the body is one field.
|
||||
let body = serde_json::json!({ "entry": key }).to_string();
|
||||
let resp = match agent
|
||||
.post(&format!("http://127.0.0.1:{}/__launch", cred.port))
|
||||
.set("Authorization", &format!("Bearer {}", cred.secret))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body)
|
||||
{
|
||||
Ok(r) => r,
|
||||
// A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets,
|
||||
// and the reason planting one is not enough to make the host run anything.
|
||||
Err(ureq::Error::Status(404, _)) => {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: the plugin does not own an entry with that key — nothing to launch"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
code,
|
||||
"plugin launch: the plugin refused to resolve the entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
error = %e,
|
||||
"plugin launch: could not reach the plugin's launch surface"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if let Err(e) = resp
|
||||
.into_reader()
|
||||
.take((MAX_BODY + 1) as u64)
|
||||
.read_to_end(&mut buf)
|
||||
{
|
||||
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
|
||||
return None;
|
||||
}
|
||||
if buf.len() > MAX_BODY {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: answer exceeds the {MAX_BODY}-byte cap"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let reply: LaunchReply = match serde_json::from_slice(&buf) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: answer was not {{command, cwd}}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
validate_reply(plugin, key, reply)
|
||||
}
|
||||
|
||||
/// The checks on what came back, split out so they can be tested without a plugin on a port.
|
||||
fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option<PluginLaunch> {
|
||||
let command = reply.command.trim().to_string();
|
||||
if command.is_empty() {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: answered an empty command"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if command.len() > MAX_COMMAND {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: command exceeds the {MAX_COMMAND}-byte cap"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Hygiene rather than a security boundary — a plugin that wanted two commands could always write
|
||||
// `a; b`, and composing the line is its job. But a launch command is ONE line: keeping control
|
||||
// characters out is what makes the logged line the line that ran, and what stops a stray `\r`
|
||||
// from mangling the Windows `cmd.exe /c` form.
|
||||
if command.chars().any(char::is_control) {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
"plugin launch: command contains control characters — refusing it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let cwd = match reply
|
||||
.cwd
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
{
|
||||
None => None,
|
||||
Some(dir) => {
|
||||
let path = PathBuf::from(dir);
|
||||
// Relative to WHAT? The host's cwd is not the plugin's, and a launch that silently ran
|
||||
// somewhere unintended is worse than one that says why it did not.
|
||||
if !path.is_absolute() {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
cwd = dir,
|
||||
"plugin launch: working directory must be absolute — refusing it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
};
|
||||
Some(PluginLaunch { command, cwd })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that
|
||||
/// yields the raw request text — so the assertions about what the HOST sent (method, path,
|
||||
/// bearer, body) live in the test thread, where a failure reads as a failure.
|
||||
fn stub_plugin(status: u16, body: &'static str) -> (u16, std::thread::JoinHandle<String>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().expect("accept");
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
// Read until the body named by Content-Length has arrived (ureq always sends one here).
|
||||
loop {
|
||||
let n = sock.read(&mut chunk).expect("read request");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
let text = String::from_utf8_lossy(&buf).to_string();
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let len = text[..end]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case("content-length")
|
||||
.then(|| v.trim().parse::<usize>().ok())?
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if buf.len() >= end + 4 + len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\nContent-Type: application/json\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
sock.write_all(resp.as_bytes()).expect("write response");
|
||||
let _ = sock.flush();
|
||||
String::from_utf8_lossy(&buf).to_string()
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asks_the_registered_plugin_and_takes_its_answer() {
|
||||
let (port, server) =
|
||||
stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#);
|
||||
crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t");
|
||||
|
||||
let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe");
|
||||
assert_eq!(got.command, "retroarch 'smw.sfc'");
|
||||
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu")));
|
||||
|
||||
let req = server.join().expect("stub thread");
|
||||
assert!(req.starts_with("POST /__launch "), "request was {req:?}");
|
||||
// The plugin's own per-boot secret, the same credential the console proxy presents.
|
||||
assert!(
|
||||
req.contains("Bearer s3cr3t"),
|
||||
"the ask must authenticate: {req:?}"
|
||||
);
|
||||
// The entry key is what the plugin resolves against its own state — it must be on the wire.
|
||||
assert!(
|
||||
req.contains(r#""entry":"snes/smw.sfc""#),
|
||||
"body was {req:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_means_the_plugin_disowns_the_entry() {
|
||||
// The forged-entry case: planting a library row is not enough, because the plugin that would
|
||||
// have to answer for it never published one.
|
||||
let (port, server) = stub_plugin(404, r#"{"error":"no launchable entry \"forged\""}"#);
|
||||
crate::mgmt::register_ui_for_test("stub-disowner", port, "s");
|
||||
|
||||
assert!(ask_plugin_launch("stub-disowner", "forged").is_none());
|
||||
server.join().expect("stub thread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unregistered_provider_resolves_to_nothing() {
|
||||
// No live plugin, no port to dial, no launch — and no panic.
|
||||
assert!(ask_plugin_launch("no-such-plugin-is-registered", "k").is_none());
|
||||
}
|
||||
|
||||
fn reply(command: &str, cwd: Option<&str>) -> LaunchReply {
|
||||
LaunchReply {
|
||||
command: command.into(),
|
||||
cwd: cwd.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_keys_are_bounded_and_printable() {
|
||||
assert!(valid_plugin_entry_key("snes/Super Mario World.sfc"));
|
||||
assert!(!valid_plugin_entry_key(""));
|
||||
assert!(!valid_plugin_entry_key("with\nnewline"));
|
||||
assert!(!valid_plugin_entry_key("with\0nul"));
|
||||
assert!(!valid_plugin_entry_key(&"x".repeat(513)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usable_answer_passes_through_trimmed() {
|
||||
let got = validate_reply(
|
||||
"rom-manager",
|
||||
"snes/smw",
|
||||
reply(" retroarch 'smw.sfc' \n", None),
|
||||
)
|
||||
.expect("usable");
|
||||
assert_eq!(got.command, "retroarch 'smw.sfc'");
|
||||
assert!(got.cwd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_oversized_and_control_char_commands_are_refused() {
|
||||
assert!(validate_reply("p", "k", reply(" ", None)).is_none());
|
||||
assert!(validate_reply("p", "k", reply(&"x".repeat(MAX_COMMAND + 1), None)).is_none());
|
||||
// The interesting one: a second line smuggled into what the host logs as a single command.
|
||||
assert!(validate_reply("p", "k", reply("retroarch rom\nrm -rf ~", None)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_directory_must_be_absolute() {
|
||||
let abs = if cfg!(windows) { r"C:\emu" } else { "/opt/emu" };
|
||||
let got = validate_reply("p", "k", reply("run", Some(abs))).expect("absolute cwd is fine");
|
||||
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(abs)));
|
||||
assert!(validate_reply("p", "k", reply("run", Some("emu/cores"))).is_none());
|
||||
// An empty/whitespace cwd is "no preference", not a refusal.
|
||||
assert!(validate_reply("p", "k", reply("run", Some(" ")))
|
||||
.expect("blank cwd is tolerated")
|
||||
.cwd
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -382,23 +382,6 @@ fn real_main() -> Result<()> {
|
||||
// driver to a stray second host started while the service sat idle.
|
||||
#[cfg(target_os = "windows")]
|
||||
vdisplay::manager::claim_instance_eagerly();
|
||||
// Clean-cursor start (design/windows-cursor-model-determinism.md §4.3): clear any
|
||||
// sticky IddCx hardware-cursor declare left on the adapter by an EARLIER boot's
|
||||
// desktop-mode session. That declare is irrevocable and adapter-wide, so without this
|
||||
// every capture-latched session on the box self-composites the pointer for the rest of
|
||||
// the adapter's life — paying a full-frame copy per visible-pointer frame and drawing
|
||||
// our straight-alpha approximation of an XOR cursor — when the OS would otherwise
|
||||
// composite it natively, for free, at full fidelity.
|
||||
//
|
||||
// It is NOT enough to wait for a reboot: with Fast Startup on (the Windows default) a
|
||||
// shutdown+power-on is a hiberboot that RESTORES session 0 and its drivers, so the
|
||||
// declare survives what the operator calls a reboot (measured: Kernel-Boot event id 27
|
||||
// `0x1`, and `lsass`/`services` keeping their pre-"reboot" start times). Only a cold
|
||||
// boot or a device restart actually clears it — and the device restart costs 0.07 s.
|
||||
//
|
||||
// Runs HERE, before any session holds a display: the restart tears the adapter down.
|
||||
#[cfg(target_os = "windows")]
|
||||
vdisplay::driver::restart_device_for_clean_cursor();
|
||||
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
|
||||
// monitor devnodes a previous host disabled for an Exclusive session and never
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
@@ -861,6 +844,7 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
let mut bitrate_mbps = 20u64;
|
||||
let mut out: Option<PathBuf> = None;
|
||||
let mut loopback = true;
|
||||
let mut wire_chunk: Option<usize> = None;
|
||||
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -907,7 +891,13 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
"h264" => Codec::H264,
|
||||
"h265" | "hevc" => Codec::H265,
|
||||
"av1" => Codec::Av1,
|
||||
other => bail!("unknown --codec '{other}' (h264|h265|av1)"),
|
||||
// The spike is the only way to drive a PyroWave capture→encode pass without
|
||||
// a client, which is what the Linux-host PyroWave work measures against.
|
||||
// Needs the `pyrowave` feature (default-on) and pairs with
|
||||
// `PUNKTFUNK_ENCODER=pyrowave`, which is what puts the CAPTURE side on the
|
||||
// raw-dmabuf passthrough.
|
||||
"pyrowave" => Codec::PyroWave,
|
||||
other => bail!("unknown --codec '{other}' (h264|h265|av1|pyrowave)"),
|
||||
}
|
||||
}
|
||||
"--bitrate" => {
|
||||
@@ -917,6 +907,12 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
}
|
||||
"--out" => out = Some(PathBuf::from(next()?)),
|
||||
"--no-loopback" => loopback = false,
|
||||
"--wire-chunk" => {
|
||||
let v: usize = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --wire-chunk (bytes)"))?;
|
||||
wire_chunk = (v > 0).then_some(v);
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
@@ -951,6 +947,7 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
|
||||
out,
|
||||
loopback,
|
||||
wire_chunk,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1024,11 +1021,18 @@ SPIKE OPTIONS:
|
||||
KWin virtual output at --width x --height and captures it
|
||||
--seconds <N> capture duration in seconds (default: 5)
|
||||
--fps <N> target frame rate (default: 60)
|
||||
--codec <h264|h265|av1> NVENC codec (default: h265)
|
||||
--codec <h264|h265|av1|pyrowave>
|
||||
encode codec (default: h265). 'pyrowave' also wants
|
||||
PUNKTFUNK_ENCODER=pyrowave so capture takes the passthrough
|
||||
--bitrate <MBPS> target bitrate in Mbps (default: 20)
|
||||
--width <W> --height <H> synthetic source size (default: 1920x1080)
|
||||
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
|
||||
--no-loopback skip the punktfunk_core round-trip verification
|
||||
--wire-chunk <BYTES> PyroWave datagram-aligned packetization at this shard payload
|
||||
(a real session passes its negotiated shard_payload, e.g. 1408).
|
||||
With PUNKTFUNK_PYROWAVE_STREAMED_AU=1 also armed, the AU is
|
||||
drained through poll_chunk and sealed as a STREAMED wire frame
|
||||
(VIDEO_CAP_STREAMED_AU), then byte-verified by the loopback
|
||||
-h, --help this help
|
||||
|
||||
NOTES:
|
||||
|
||||
@@ -47,6 +47,14 @@ mod store;
|
||||
mod tests;
|
||||
mod update;
|
||||
|
||||
/// Lets `library::plugin_launch`'s tests put a stub plugin in the registry (test-only).
|
||||
#[cfg(test)]
|
||||
pub(crate) use plugins::register_ui_for_test;
|
||||
/// The launch path asks a library plugin what to run for its own entries, and needs the loopback
|
||||
/// credential this process already holds for it. Re-exported (rather than opening the whole
|
||||
/// `plugins` module crate-wide) so these two are the ONLY things `mgmt` lends to the library side.
|
||||
pub(crate) use plugins::ui_credential;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
pub const DEFAULT_PORT: u16 = 47990;
|
||||
|
||||
@@ -286,6 +286,38 @@ pub(crate) fn live_plugin_ids() -> Vec<String> {
|
||||
registry().live_ids()
|
||||
}
|
||||
|
||||
/// The loopback `{port, secret}` a live plugin serves its UI on — the credential the **host itself**
|
||||
/// presents when it asks a library plugin what to run for one of its `plugin`-kind launch entries
|
||||
/// ([`crate::library::ask_plugin_launch`]).
|
||||
///
|
||||
/// The same lookup the console proxy gets from `GET /plugins/{id}/ui-credential`, exposed in-process
|
||||
/// so the launch path never round-trips through the management API to reach a port this process
|
||||
/// already holds. `None` for an unknown, expired, or UI-less plugin — which the launch path reports
|
||||
/// as "no recipe", exactly like any other unresolvable entry.
|
||||
pub(crate) fn ui_credential(id: &str) -> Option<UiCredential> {
|
||||
registry().credential(id)
|
||||
}
|
||||
|
||||
/// Put a live UI registration in the registry directly — **test only**, so the launch path
|
||||
/// ([`crate::library::ask_plugin_launch`]) can be driven against a stub server without standing up
|
||||
/// the whole management router just to reach `PUT /plugins/{id}`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_ui_for_test(id: &str, port: u16, secret: &str) {
|
||||
registry().upsert(
|
||||
id,
|
||||
Valid {
|
||||
title: id.to_string(),
|
||||
version: None,
|
||||
ui: Some(StoredUi {
|
||||
port,
|
||||
secret: secret.to_string(),
|
||||
icon: None,
|
||||
}),
|
||||
category: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- validation
|
||||
|
||||
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
||||
|
||||
@@ -1148,7 +1148,12 @@ async fn serve_session(
|
||||
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
|
||||
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
|
||||
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
|
||||
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
|
||||
wire_mtu::spawn_watch(
|
||||
conn.clone(),
|
||||
welcome.shard_payload as usize,
|
||||
hello.max_shard_payload,
|
||||
shard_reneg,
|
||||
);
|
||||
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||
@@ -1507,11 +1512,17 @@ async fn serve_session(
|
||||
// launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what
|
||||
// to call the title, and how to recognize its process once a launcher has handed off
|
||||
// (design/session-game-lifetime.md §4).
|
||||
let launch_target =
|
||||
hello
|
||||
.launch
|
||||
.as_deref()
|
||||
.and_then(|id| match crate::library::resolve_launch(id) {
|
||||
//
|
||||
// On a blocking thread: a `plugin`-kind entry resolves by asking the plugin that owns it over
|
||||
// loopback (`library::ask_plugin_launch`), and this is an async context.
|
||||
let launch_target = match hello.launch.as_deref() {
|
||||
None => None,
|
||||
Some(id) => {
|
||||
let owned = id.to_string();
|
||||
match tokio::task::spawn_blocking(move || crate::library::resolve_launch(&owned))
|
||||
.await
|
||||
.context("resolve the session's library launch")?
|
||||
{
|
||||
Some(t) => {
|
||||
tracing::info!(
|
||||
launch_id = id,
|
||||
@@ -1528,7 +1539,9 @@ async fn serve_session(
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let launch_for_dp = launch_target.as_ref().and(hello.launch.clone());
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
||||
@@ -33,6 +33,52 @@ fn pick_compositor(
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this connect pinned at a compositor that is not actually running?
|
||||
///
|
||||
/// Pure (the I/O shell passes in the observed liveness) so the interaction is unit-tested, because
|
||||
/// it is invisible from the outside: an operator pin puts its backend into
|
||||
/// [`crate::vdisplay::available`] unconditionally AND skips `apply_session_env`'s
|
||||
/// `XDG_CURRENT_DESKTOP` scrub, so [`pick_compositor`] hands back a compositor that may be a corpse
|
||||
/// and its `None` (recover) arm can never fire. [`Compositor::Gamescope`] is exempt — it stands its
|
||||
/// own session up, which is the whole reason a headless box pins it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn pinned_at_a_dead_session(
|
||||
overridden: bool,
|
||||
chosen: crate::vdisplay::Compositor,
|
||||
live: crate::vdisplay::ActiveKind,
|
||||
) -> bool {
|
||||
overridden && chosen.needs_live_session() && live == crate::vdisplay::ActiveKind::None
|
||||
}
|
||||
|
||||
/// The handshake error for "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 or a reboot).
|
||||
///
|
||||
/// Fires the operator's recovery hook (debounced) on the way out when one is configured, so the
|
||||
/// client's retry a few seconds later lands in a recovered desktop. `pinned` names the
|
||||
/// `PUNKTFUNK_COMPOSITOR` value when the pin is what got us here, so the message can say which knob
|
||||
/// to change rather than the generic advice to *set* the knob that caused it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn no_live_session(pinned: Option<&str>) -> anyhow::Error {
|
||||
if crate::vdisplay::try_recover_session() {
|
||||
return anyhow::anyhow!(
|
||||
"no live graphical session for this uid — host session recovery launched \
|
||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||
);
|
||||
}
|
||||
match pinned {
|
||||
Some(pin) => anyhow::anyhow!(
|
||||
"PUNKTFUNK_COMPOSITOR={pin} pins this host to a backend that can only attach to an \
|
||||
already-running compositor, and no graphical session is live for this uid — start a \
|
||||
session, pin `gamescope` (it stands its own up), or set PUNKTFUNK_RECOVER_SESSION_CMD"
|
||||
),
|
||||
None => anyhow::anyhow!(
|
||||
"no usable compositor (no live graphical session for this uid; set \
|
||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
|
||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||
@@ -61,13 +107,21 @@ pub(super) fn resolve_compositor(
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = pf_host_config::config().compositor.is_some();
|
||||
// Liveness is read on BOTH paths. The auto path retargets the process env at the live
|
||||
// session (below); the PINNED path needs it too, because a pin names a BACKEND, not a
|
||||
// running session — and a pin whose compositor has died used to be indistinguishable from a
|
||||
// healthy one here (it skips `apply_session_env`'s `XDG_CURRENT_DESKTOP` scrub and lands
|
||||
// itself in `available()`, so `pick_compositor` could never return `None`). That combination
|
||||
// marched every client through 8 doomed `create` retries and left the operator's
|
||||
// `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable — see the `needs_live_session` gate below.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
let detected = if overridden {
|
||||
crate::vdisplay::detect().ok()
|
||||
} else {
|
||||
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
|
||||
// every backend (video capture + input) this connect opens against the active session —
|
||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
//
|
||||
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||
// reuses a node id from the dead instance.
|
||||
@@ -84,14 +138,36 @@ pub(super) fn resolve_compositor(
|
||||
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
||||
if dedicated_launch && !overridden {
|
||||
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
?route,
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
||||
);
|
||||
return Ok((Compositor::Gamescope, route));
|
||||
// backend + input to the spawn sub-mode. An explicit operator compositor pin still outranks
|
||||
// it — but says so out loud (below), because a silent veto is indistinguishable from the
|
||||
// feature being broken.
|
||||
if dedicated_launch {
|
||||
if overridden {
|
||||
// The pin still wins (it is the operator's explicit, hand-configured knob), but it
|
||||
// must NEVER win silently: the console goes on displaying `game_session=dedicated`
|
||||
// while every launch lands in the pinned session instead, and nothing in the log
|
||||
// connects the two. That cost a full triage on a box whose `PUNKTFUNK_COMPOSITOR`
|
||||
// was a forgotten validation leftover — the setting had never once taken effect and
|
||||
// the only evidence was the ABSENCE of the info! line below.
|
||||
tracing::warn!(
|
||||
pin = pf_host_config::config()
|
||||
.compositor
|
||||
.as_deref()
|
||||
.unwrap_or("-"),
|
||||
"game_session=dedicated asked for this launch's OWN headless gamescope, but \
|
||||
PUNKTFUNK_COMPOSITOR pins this host to a backend — the operator pin wins and \
|
||||
the game launches into the pinned session instead. Unset PUNKTFUNK_COMPOSITOR \
|
||||
to get dedicated game sessions."
|
||||
);
|
||||
} else {
|
||||
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
?route,
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client \
|
||||
mode"
|
||||
);
|
||||
return Ok((Compositor::Gamescope, route));
|
||||
}
|
||||
}
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = match pick_compositor(pref, &available, detected) {
|
||||
@@ -112,23 +188,18 @@ pub(super) fn resolve_compositor(
|
||||
);
|
||||
Compositor::Gamescope
|
||||
}
|
||||
None => {
|
||||
// The state a compositor crash leaves behind (gnome-shell
|
||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||
// its next knock lands in the recovered desktop.
|
||||
if crate::vdisplay::try_recover_session() {
|
||||
anyhow::bail!(
|
||||
"no live graphical session for this uid — host session recovery launched \
|
||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"no usable compositor (no live graphical session for this uid; set \
|
||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||
);
|
||||
}
|
||||
None => return Err(no_live_session(None)),
|
||||
};
|
||||
// Same dead-session exit, reached the other way: a pin puts its backend in `available()`
|
||||
// unconditionally, so `pick_compositor` above can hand back a compositor that is not
|
||||
// actually running and the `None` arm never fires. Check the backend's own requirement
|
||||
// against observed liveness instead of trusting the pin. Gamescope is exempt — it stands
|
||||
// its own session up, which is the whole point of pinning it on a headless box.
|
||||
if pinned_at_a_dead_session(overridden, chosen, active.kind) {
|
||||
return Err(no_live_session(
|
||||
pf_host_config::config().compositor.as_deref(),
|
||||
));
|
||||
}
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). The
|
||||
// route travels back to the caller as a VALUE and is carried on the backend instance — an
|
||||
@@ -170,6 +241,44 @@ mod tests {
|
||||
use super::pick_compositor;
|
||||
use punktfunk_core::config::CompositorPref;
|
||||
|
||||
/// A pin at a compositor that ISN'T RUNNING must take the recovery exit rather than march the
|
||||
/// client into a bring-up that can only fail.
|
||||
///
|
||||
/// The regression this pins down: `PUNKTFUNK_COMPOSITOR=mutter` on a box whose gnome-shell had
|
||||
/// segfaulted. The pin put Mutter in `available()` and suppressed the `XDG_CURRENT_DESKTOP`
|
||||
/// scrub, so every connect "resolved" happily and then spent 8 retries on
|
||||
/// `RemoteDesktop.CreateSession: ServiceUnknown` — while the operator's
|
||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` sat unreachable behind a `None` arm that could never fire.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn a_pin_at_a_dead_session_recovers_instead_of_retrying() {
|
||||
use super::pinned_at_a_dead_session as dead;
|
||||
use crate::vdisplay::{ActiveKind, Compositor::*};
|
||||
// The bug: pinned to a desktop backend with nothing live for this uid.
|
||||
assert!(dead(true, Mutter, ActiveKind::None));
|
||||
assert!(dead(true, Kwin, ActiveKind::None));
|
||||
assert!(dead(true, Wlroots, ActiveKind::None));
|
||||
assert!(dead(true, Hyprland, ActiveKind::None));
|
||||
// Pinned but the session IS up — the ordinary case, must not bail.
|
||||
assert!(!dead(true, Mutter, ActiveKind::DesktopGnome));
|
||||
// Gamescope stands its own session up from nothing: pinning it on a headless box is a
|
||||
// SUPPORTED setup, not a dead session. (This is the .21 no-login workaround — never break it.)
|
||||
assert!(!dead(true, Gamescope, ActiveKind::None));
|
||||
// Unpinned is untouched: the auto path already reaches `pick_compositor`'s `None` arm via
|
||||
// `compositor_for_kind(ActiveKind::None)`, and it owns the managed-takeover case.
|
||||
assert!(!dead(false, Mutter, ActiveKind::None));
|
||||
}
|
||||
|
||||
/// gamescope is the ONLY backend that can serve a connect with no session already running.
|
||||
#[test]
|
||||
fn only_gamescope_survives_a_dead_session() {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
assert!(!Gamescope.needs_live_session());
|
||||
for c in [Mutter, Kwin, Wlroots, Hyprland] {
|
||||
assert!(c.needs_live_session(), "{c:?} needs a live compositor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositor_resolution_precedence() {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
|
||||
@@ -148,7 +148,6 @@ pub(super) async fn negotiate(
|
||||
Option<crate::vdisplay::GamescopeRoute>,
|
||||
Option<super::stream::PrepHandle>,
|
||||
)> {
|
||||
let peer = conn.remote_address();
|
||||
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
if hello.abi_version != punktfunk_core::WIRE_VERSION {
|
||||
close_rejected(
|
||||
@@ -270,13 +269,15 @@ pub(super) async fn negotiate(
|
||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||
// (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a
|
||||
// command — on Windows the concrete process is resolved at launch time instead.)
|
||||
// `launch_is_resolvable`, not a full `resolve_launch`: a `plugin`-kind entry's command
|
||||
// is fetched from the owning plugin over loopback, and this runs on the async path. The
|
||||
// cheap check answers the only question asked here (does this tile launch anything?)
|
||||
// without a blocking call — see `library::launch_is_resolvable`.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let has_resolvable_launch = hello
|
||||
.launch
|
||||
.as_deref()
|
||||
.and_then(crate::library::resolve_launch)
|
||||
.and_then(|t| t.command)
|
||||
.is_some();
|
||||
.is_some_and(crate::library::launch_is_resolvable);
|
||||
#[cfg(target_os = "windows")]
|
||||
let has_resolvable_launch = false;
|
||||
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||
@@ -495,6 +496,11 @@ pub(super) async fn negotiate(
|
||||
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||
let udp_port = data_sock.local_addr()?.port();
|
||||
|
||||
// The session's video geometry (see the `shard_payload` field below). Resolved before the
|
||||
// Welcome struct because a path a previous session proved jumbo is given a bounded moment
|
||||
// to re-prove itself live on THIS connection — the awaited part of `negotiated_shard_payload`.
|
||||
let shard_payload = wire_mtu::negotiated_shard_payload(conn, hello.max_shard_payload).await;
|
||||
|
||||
let mut key = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
|
||||
@@ -546,14 +552,15 @@ pub(super) async fn negotiate(
|
||||
// hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride
|
||||
// inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling
|
||||
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
|
||||
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
|
||||
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
|
||||
// budget learned from a prior session whose QUIC MTU discovery settled below the
|
||||
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
|
||||
// shape — small flows pass, the stream is an endless black screen), then this family
|
||||
// default. Healthy paths take the default branch and are byte-identical to before.
|
||||
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
|
||||
// Negotiated, so the client follows.
|
||||
// Resolution order (wire_mtu.rs): a JUMBO start (≈8900) on a path a previous session
|
||||
// proved AND this connection has just re-proved live, then the `PUNKTFUNK_WIRE_MTU`
|
||||
// operator override, then a path budget learned from a prior session whose QUIC MTU
|
||||
// discovery settled below the video-datagram ceiling (the "VPN on the host blackholes
|
||||
// every video packet" field shape — small flows pass, the stream is an endless black
|
||||
// screen), then this family default. Healthy paths take the default branch and are
|
||||
// byte-identical to before.
|
||||
shard_payload: shard_payload as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt,
|
||||
|
||||
@@ -1553,6 +1553,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
encoder supports chunked output"
|
||||
);
|
||||
}
|
||||
// A mode switch the control task accepted BEFORE the pipeline was built (the client connects at
|
||||
// one mode and immediately asks for its real one — a fractional-scale panel resolving its native
|
||||
// pixel size does exactly this, ~3 s ahead of bring-up finishing) used to be served the long way
|
||||
// round: build the whole pipeline at the now-stale mode, then immediately rebuild at the new one
|
||||
// in the loop below. That wastes a display create + capture attach + encoder open on every such
|
||||
// connect, and on GNOME it is actively destructive — the rebuild is create-before-drop, so two
|
||||
// `RecordVirtual` monitors ~400 ms apart segfault mutter 50.4 inside
|
||||
// `meta_monitor_manager_rebuild`, taking down the whole desktop session (and with it the game
|
||||
// just launched into it, which then looks like the GAME crashed). Adopt the newest queued mode
|
||||
// here and build ONCE.
|
||||
//
|
||||
// Only on the inline path: a PREPARED pipeline is already built at the old mode, so adopting a
|
||||
// new `mode` there would just make this variable disagree with the display that exists. Those
|
||||
// sessions keep the rebuild-in-the-loop behavior. No accept ack is owed either way — the
|
||||
// client's mode slot already flipped when control accepted the switch (it acks on accept, not
|
||||
// on rebuild); the H2/H3 *correction* ack the rebuild would have sent is preserved below.
|
||||
let mut mode = mode;
|
||||
let mut adopted_at_bringup = false;
|
||||
if prepared.is_none() {
|
||||
let mut queued = None;
|
||||
while let Ok(m) = reconfig.try_recv() {
|
||||
queued = Some(m);
|
||||
}
|
||||
if let Some(m) = queued.filter(|m| *m != mode) {
|
||||
adopted_at_bringup = true;
|
||||
tracing::info!(
|
||||
stale = ?mode,
|
||||
adopted = ?m,
|
||||
"a mode switch was accepted before bring-up finished — building at the new mode \
|
||||
instead of building twice"
|
||||
);
|
||||
mode = m;
|
||||
// Mirror the loop's rebuild: PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin, so
|
||||
// a resolution change moves the operating point. Explicit client rates stay put.
|
||||
if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
bitrate_kbps =
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &mode, plan.chroma, plan.bit_depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
compositor = compositor.id(),
|
||||
?mode,
|
||||
@@ -1666,6 +1706,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
// H2/H3 correction, carried over from the rebuild this bring-up replaced: the client APPLIED
|
||||
// the mode when control accepted it, but the backend may have honored a different one (KWin
|
||||
// caps a virtual output's refresh; a fallback delivers the size the source actually produces).
|
||||
// Only for a mode adopted at bring-up — an ordinary connect's mode came from the Welcome, not
|
||||
// from an accept the client has already acted on, so it is not owed a correction here.
|
||||
if adopted_at_bringup {
|
||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||
if actual != mode {
|
||||
let _ = reconfig_result_tx.send(Reconfigured {
|
||||
accepted: true,
|
||||
mode: actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded
|
||||
//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases
|
||||
//! the record (the learn/heal loop is self-correcting in both directions).
|
||||
//! - **Grow** (PW7a) — the mirror image, for the jumbo half: a connection whose discovery
|
||||
//! settles at the sealed JUMBO size has proven the path carries ~8.9 KB video datagrams, and
|
||||
//! the next session on that same path *starts* there instead of at the 1500-byte default.
|
||||
//! PyroWave sessions cannot be re-keyed mid-stream (the client's parse window is the
|
||||
//! `Welcome` value, read once over the C ABI), so the session-start value is the ONLY way
|
||||
//! they ever reach jumbo — and it is exactly where ~6× fewer datagrams per frame is worth
|
||||
//! the most. See [`jumbo_session_start`] for why a remembered verdict alone is never
|
||||
//! allowed to seal one byte above the default.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
@@ -63,10 +71,155 @@ fn learned() -> &'static Mutex<HashMap<IpAddr, u16>> {
|
||||
LEARNED.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the
|
||||
/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever
|
||||
/// the result differs from the default.
|
||||
pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
|
||||
/// Identity of a PATH, not of a peer — the key the jumbo verdict is filed under.
|
||||
///
|
||||
/// The clamp above is keyed by peer IP alone, and that is safe *because being wrong is benign*:
|
||||
/// a stale clamp only makes video datagrams smaller than they had to be. A stale GROW is the
|
||||
/// opposite — one oversized datagram on a 1500-byte path is silently dropped, which is the
|
||||
/// "connects fine, black screen forever" shape this whole module exists to kill. So the grow
|
||||
/// keys strictly: a verdict earned over the host's 10 GbE NIC does not apply to the same peer
|
||||
/// IP reached over the host's Wi-Fi or a VPN adapter, because those are different routes with
|
||||
/// different MTUs.
|
||||
///
|
||||
/// `local` is `Connection::local_ip()` (the address the connection was actually received on);
|
||||
/// `None` where the platform can't report it, which degrades this key to the clamp's — safely,
|
||||
/// because the live re-proof in [`jumbo_session_start`] is what actually protects the grow.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
struct PathKey {
|
||||
local: Option<IpAddr>,
|
||||
peer: IpAddr,
|
||||
}
|
||||
|
||||
/// A path that a completed MTU-discovery search proved carries jumbo video datagrams.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct JumboVerdict {
|
||||
/// The settled UDP-payload budget the proof measured.
|
||||
udp_budget: u16,
|
||||
/// The operator's jumbo target when the proof was taken. A changed `PUNKTFUNK_JUMBO` /
|
||||
/// `PUNKTFUNK_WIRE_MTU` invalidates it rather than being silently reinterpreted.
|
||||
target_wire_mtu: usize,
|
||||
/// When it was taken ([`JUMBO_VERDICT_TTL`]).
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// How long a jumbo verdict may be redeemed for. Contrary evidence erases it long before this
|
||||
/// (any settle below the sealed target, on any later session over the same path — the same
|
||||
/// self-correction the clamp has), so the TTL is not the safety mechanism; it is a bound on how
|
||||
/// stale an *unrefreshed* memory can get, for the case where the path changes while no session
|
||||
/// is running.
|
||||
const JUMBO_VERDICT_TTL: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
|
||||
|
||||
/// How long the `Welcome` may wait for THIS connection's MTU discovery to re-prove a jumbo
|
||||
/// path.
|
||||
///
|
||||
/// The wait is structural, not laziness: every connection restarts discovery from ~1200 bytes,
|
||||
/// so the live proof the grow requires does not exist yet when the `Welcome` is built — and the
|
||||
/// binary search up to sealed-jumbo needs an ACKED probe per step, each of which a peer may sit
|
||||
/// on for its ack delay. Without a wait the gate would never pass and the feature would be dead.
|
||||
///
|
||||
/// It is honestly on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
|
||||
/// THEN kicks the display prep), so it is bounded, returns the instant the proof lands, and is
|
||||
/// entered ONLY for a path a previous session already proved jumbo — i.e. an opted-in operator
|
||||
/// on a jumbo LAN, never anyone else. The worst case (the full wait, no proof) is the moved
|
||||
/// laptop, and it is self-limiting: that session's watcher erases the verdict, so the next
|
||||
/// connect doesn't wait at all.
|
||||
const JUMBO_PROOF_WAIT: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
const JUMBO_PROOF_POLL: std::time::Duration = std::time::Duration::from_millis(10);
|
||||
|
||||
/// Proven-jumbo paths. Same lifetime rules as [`learned`] — in-memory, re-earned in one session
|
||||
/// after a host restart.
|
||||
fn jumbo_verdicts() -> &'static Mutex<HashMap<PathKey, JumboVerdict>> {
|
||||
static JUMBO: OnceLock<Mutex<HashMap<PathKey, JumboVerdict>>> = OnceLock::new();
|
||||
JUMBO.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn path_key(conn: &quinn::Connection) -> PathKey {
|
||||
PathKey {
|
||||
local: conn.local_ip(),
|
||||
peer: conn.remote_address().ip(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the session-start jumbo decision reads. Every field but `proven_udp_budget` is
|
||||
/// observed on THIS connection during THIS handshake — which is the point (see
|
||||
/// [`jumbo_session_start`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct JumboStart {
|
||||
/// The host operator's opt-in ([`jumbo_wire_mtu`]) — `None` = no jumbo, ever.
|
||||
target_wire_mtu: Option<usize>,
|
||||
/// `Hello::max_shard_payload`: the client's own receive ceiling (0 = legacy client, which
|
||||
/// never gets a geometry it didn't ask for).
|
||||
client_ceiling: u16,
|
||||
/// `conn.stats().path.current_mtu` right now: the largest UDP payload quinn has had ACKED
|
||||
/// on this connection.
|
||||
live_udp_mtu: u16,
|
||||
/// What a previous session over this same [`PathKey`] settled at, if any.
|
||||
proven_udp_budget: Option<u16>,
|
||||
/// The constrained-path clamp [`learned`] for this peer, if any. Contradictory evidence
|
||||
/// (this peer black-screened on a small MTU recently) vetoes the grow — the two memories
|
||||
/// are keyed differently and the safe one wins.
|
||||
clamped_udp_budget: Option<u16>,
|
||||
}
|
||||
|
||||
/// The jumbo shard payload a session to `peer` could use, or `None` when there is nothing to
|
||||
/// gain (no opt-in, a legacy/low client ceiling, or a target that isn't bigger than the family
|
||||
/// default). Shared by the decision, the wait, and the watcher so all three agree on the number.
|
||||
fn jumbo_target(
|
||||
target_wire_mtu: Option<usize>,
|
||||
client_ceiling: u16,
|
||||
peer: IpAddr,
|
||||
) -> Option<usize> {
|
||||
let mtu = target_wire_mtu?;
|
||||
let t = jumbo_shard_payload_for(mtu, peer).min(client_ceiling as usize);
|
||||
let t = t - t % 2; // FEC requires even shards
|
||||
(t > mtu1500_shard_payload_for(peer)).then_some(t)
|
||||
}
|
||||
|
||||
/// The session-START jumbo decision: `Some(shard_payload)` only when every gate below holds.
|
||||
///
|
||||
/// **Why a remembered verdict is never enough.** A laptop that proved jumbo on the wired LAN
|
||||
/// and comes back on Wi-Fi, a switch that lost its jumbo config, a client IP recycled by DHCP —
|
||||
/// all of them present a path that cannot carry an 8.9 KB datagram, and a PyroWave session
|
||||
/// sealed at that size cannot be re-keyed mid-stream, so it would black-screen for its whole
|
||||
/// life. The memory therefore only decides whether it is worth WAITING for a proof; what
|
||||
/// actually authorises the grow is `live_udp_mtu` — a datagram of exactly that size, acked by
|
||||
/// this client, on this connection, seconds ago. That is why this is as safe as the clamp
|
||||
/// despite the failure modes being opposite: a wrong memory cannot produce a jumbo `Welcome`,
|
||||
/// only a live measurement can.
|
||||
///
|
||||
/// The gates, in order: the host operator opted in; the client advertised enough receive
|
||||
/// headroom; the target beats the family default (nothing to gain otherwise); no constrained-path
|
||||
/// clamp contradicts it; a prior session over this exact path settled at or above the sealed
|
||||
/// target; and this connection has re-proven it live.
|
||||
fn jumbo_session_start(i: JumboStart, peer: IpAddr) -> Option<usize> {
|
||||
let target = jumbo_target(i.target_wire_mtu, i.client_ceiling, peer)?;
|
||||
let sealed = sealed_datagram_bytes(target);
|
||||
if let Some(clamp) = i.clamped_udp_budget {
|
||||
if (clamp as usize) < sealed {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if (i.proven_udp_budget? as usize) < sealed {
|
||||
return None;
|
||||
}
|
||||
if (i.live_udp_mtu as usize) < sealed {
|
||||
return None;
|
||||
}
|
||||
Some(target)
|
||||
}
|
||||
|
||||
/// The shard payload for a new session on `conn`: a proven-jumbo grow, else the
|
||||
/// `PUNKTFUNK_WIRE_MTU` override, else the peer's learned path budget, else the family default
|
||||
/// (today's exact behavior). Logs whenever the result differs from the default.
|
||||
///
|
||||
/// `client_ceiling` is the client's `Hello::max_shard_payload`. Async only for the bounded
|
||||
/// [`JUMBO_PROOF_WAIT`], which is entered *only* on a path a previous session already proved
|
||||
/// jumbo — every other session resolves without awaiting anything.
|
||||
pub(super) async fn negotiated_shard_payload(
|
||||
conn: &quinn::Connection,
|
||||
client_ceiling: u16,
|
||||
) -> usize {
|
||||
let peer = conn.remote_address().ip();
|
||||
let env = match std::env::var("PUNKTFUNK_WIRE_MTU") {
|
||||
Ok(v) => match v.trim().parse::<usize>() {
|
||||
Ok(mtu) => Some(mtu),
|
||||
@@ -78,13 +231,80 @@ pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
|
||||
Err(_) => None,
|
||||
};
|
||||
let learned_budget = learned().lock().unwrap().get(&peer).copied();
|
||||
resolve(env, learned_budget, peer)
|
||||
let target_wire_mtu = jumbo_wire_mtu();
|
||||
let proven_udp_budget = fresh_verdict(path_key(conn), target_wire_mtu);
|
||||
let mut jumbo = JumboStart {
|
||||
target_wire_mtu,
|
||||
client_ceiling,
|
||||
live_udp_mtu: conn.stats().path.current_mtu,
|
||||
proven_udp_budget,
|
||||
clamped_udp_budget: learned_budget,
|
||||
};
|
||||
// A proven path is worth waiting a moment for: MTU discovery starts when the handshake
|
||||
// completes and needs an acked probe per binary-search step, so at `Welcome` time it may
|
||||
// simply not have got there yet. Bounded, and only on paths that already proved it once.
|
||||
let awaited_proof = proven_udp_budget
|
||||
.and_then(|_| jumbo_target(target_wire_mtu, client_ceiling, peer))
|
||||
.map(|t| sealed_datagram_bytes(t) as u16);
|
||||
if let Some(sealed) = awaited_proof {
|
||||
if jumbo.live_udp_mtu < sealed {
|
||||
let t0 = std::time::Instant::now();
|
||||
while t0.elapsed() < JUMBO_PROOF_WAIT {
|
||||
tokio::time::sleep(JUMBO_PROOF_POLL).await;
|
||||
jumbo.live_udp_mtu = conn.stats().path.current_mtu;
|
||||
if jumbo.live_udp_mtu >= sealed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
peer = %peer,
|
||||
waited_ms = t0.elapsed().as_millis() as u64,
|
||||
live_udp_mtu = jumbo.live_udp_mtu,
|
||||
needed = sealed,
|
||||
"wire MTU: waited for this connection to re-prove its jumbo path"
|
||||
);
|
||||
}
|
||||
}
|
||||
resolve(env, learned_budget, jumbo, peer)
|
||||
}
|
||||
|
||||
/// Pure resolution (env override > learned budget > family default) — the tested core of
|
||||
/// [`negotiated_shard_payload`].
|
||||
fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: IpAddr) -> usize {
|
||||
/// The peer's jumbo verdict if it is still redeemable: same operator target, inside the TTL.
|
||||
/// A verdict that fails either test is dropped on the spot rather than left to rot.
|
||||
fn fresh_verdict(key: PathKey, target_wire_mtu: Option<usize>) -> Option<u16> {
|
||||
let target = target_wire_mtu?;
|
||||
let mut map = jumbo_verdicts().lock().unwrap();
|
||||
let v = *map.get(&key)?;
|
||||
if v.target_wire_mtu != target || v.at.elapsed() > JUMBO_VERDICT_TTL {
|
||||
map.remove(&key);
|
||||
return None;
|
||||
}
|
||||
Some(v.udp_budget)
|
||||
}
|
||||
|
||||
/// Pure resolution (proven jumbo > env override > learned budget > family default) — the tested
|
||||
/// core of [`negotiated_shard_payload`].
|
||||
fn resolve(
|
||||
env_wire_mtu: Option<usize>,
|
||||
learned_udp_budget: Option<u16>,
|
||||
jumbo: JumboStart,
|
||||
peer: IpAddr,
|
||||
) -> usize {
|
||||
let default = mtu1500_shard_payload_for(peer);
|
||||
// First, because the two are mutually exclusive by construction: `jumbo_wire_mtu()` only
|
||||
// fires above 1500, and the env branch below CLAMPS to the family default, so a
|
||||
// `PUNKTFUNK_WIRE_MTU=9000` operator would otherwise get 1408 and never a jumbo start.
|
||||
if let Some(p) = jumbo_session_start(jumbo, peer) {
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
shard_payload = p,
|
||||
default,
|
||||
live_udp_mtu = jumbo.live_udp_mtu,
|
||||
proven_udp_budget = jumbo.proven_udp_budget,
|
||||
"wire MTU: session starts at the JUMBO shard — this path proved it in a previous \
|
||||
session AND re-proved it live on this connection (~6× fewer datagrams per frame)"
|
||||
);
|
||||
return p;
|
||||
}
|
||||
if let Some(mtu) = env_wire_mtu {
|
||||
let p = shard_payload_for_wire_mtu(mtu, peer);
|
||||
if p != default {
|
||||
@@ -119,34 +339,73 @@ fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: I
|
||||
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
|
||||
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
|
||||
/// the ~3–10 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
|
||||
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
|
||||
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
|
||||
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
|
||||
/// until the connection closes.
|
||||
/// verdict grows it, ack-gated, when the operator opted in. The same settled-at-jumbo reading
|
||||
/// also writes this path's next-session verdict (PW7a) — `client_ceiling` is the client's
|
||||
/// `Hello::max_shard_payload`, which decides what "jumbo" is worth proving for this peer.
|
||||
/// Spawned once per negotiated session; without a grow the task ends after the final sample
|
||||
/// (bounded ~10 s lifetime, holding only a cheap `Connection` handle) — after a grow, or on a
|
||||
/// session that STARTED jumbo, it stays as the revert guard until the connection closes.
|
||||
pub(super) fn spawn_watch(
|
||||
conn: quinn::Connection,
|
||||
session_shard_payload: usize,
|
||||
client_ceiling: u16,
|
||||
reneg: Option<ShardReneg>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let peer = conn.remote_address().ip();
|
||||
let ceiling = video_datagram_udp_ceiling() as u16;
|
||||
// The sealed size a JUMBO proof has to reach on this path (PW7a) — `None` unless the
|
||||
// operator opted in AND this client advertised the headroom. Read once: the verdict
|
||||
// records the target it was proven under, and the two must be the same number.
|
||||
let target_wire_mtu = jumbo_wire_mtu();
|
||||
let jumbo_proof =
|
||||
jumbo_target(target_wire_mtu, client_ceiling, peer).map(sealed_datagram_bytes);
|
||||
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
|
||||
// needs a loss timeout per failed probe on a constrained path — the second sample
|
||||
// covers that with margin. Max, because discovery only ever raises `current_mtu`
|
||||
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
|
||||
// lower it again).
|
||||
// lower it again). Stop early only once nothing more is expected: with a jumbo opt-in
|
||||
// the search keeps climbing past the 1500-byte ceiling, and stopping there would throw
|
||||
// away the very measurement the proof needs.
|
||||
let goal = jumbo_proof
|
||||
.unwrap_or(ceiling as usize)
|
||||
.max(ceiling as usize) as u16;
|
||||
let mut settled = 0u16;
|
||||
for wait_s in [3u64, 7] {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
|
||||
settled = settled.max(conn.stats().path.current_mtu);
|
||||
if settled >= ceiling {
|
||||
if settled >= goal {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
|
||||
let mut current = session_shard_payload;
|
||||
let mut reneg = reneg;
|
||||
// PW7a bookkeeping, before anything else can return: this is where a jumbo path earns
|
||||
// its next-session verdict — and, far more importantly, where it LOSES it. Recording
|
||||
// needs a live connection that reached the sealed target; anything else (a lower
|
||||
// settle, a connection that died before the window closed, i.e. exactly what a client
|
||||
// staring at a black screen does) erases, so the next session falls back to the
|
||||
// 1500-byte default and has to prove itself again from scratch.
|
||||
if let Some(need) = jumbo_proof {
|
||||
let key = path_key(&conn);
|
||||
if settled as usize >= need && conn.close_reason().is_none() {
|
||||
jumbo_verdicts().lock().unwrap().insert(
|
||||
key,
|
||||
JumboVerdict {
|
||||
udp_budget: settled,
|
||||
target_wire_mtu: target_wire_mtu.unwrap_or_default(),
|
||||
at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
tracing::info!(peer = %peer, discovered_udp_mtu = settled, needed = need,
|
||||
"wire MTU: this path carries JUMBO video datagrams — the next session over \
|
||||
it starts at the big shard (it still has to re-prove the path live)");
|
||||
} else if jumbo_verdicts().lock().unwrap().remove(&key).is_some() {
|
||||
tracing::info!(peer = %peer, discovered_udp_mtu = settled, needed = need,
|
||||
"wire MTU: jumbo verdict cleared — this path no longer proves it");
|
||||
}
|
||||
}
|
||||
if settled >= ceiling {
|
||||
// The path carries full-size video datagrams — erase any stale learned clamp so
|
||||
// the next session returns to the default wire.
|
||||
@@ -154,6 +413,34 @@ pub(super) fn spawn_watch(
|
||||
tracing::info!(peer = %peer,
|
||||
"wire MTU: path re-measured at full size — learned clamp cleared");
|
||||
}
|
||||
// …but "full size" is the 1500-byte ceiling, and this session may have STARTED
|
||||
// above it (a PW7a jumbo start whose path changed since the proof, or a client
|
||||
// that roamed onto a 1500-MTU link). Then every video datagram is dying right now.
|
||||
// The verdict is already erased above; heal the live wire if this session can be
|
||||
// re-keyed at all — a PyroWave client cannot (its parse window is the `Welcome`
|
||||
// value), so for those the WARN plus a corrected next session is all there is.
|
||||
if sealed_datagram_bytes(current) > settled as usize {
|
||||
tracing::warn!(
|
||||
peer = %peer,
|
||||
discovered_udp_mtu = settled,
|
||||
shard_payload = current,
|
||||
"wire MTU: this session started at a JUMBO shard but the path does not \
|
||||
carry it — video datagrams are oversized for a hop, which streams as a \
|
||||
black screen with zero reported loss. The jumbo verdict for this path is \
|
||||
cleared: the next connect starts at the standard 1500-byte wire."
|
||||
);
|
||||
if let Some(r) = reneg.as_ref() {
|
||||
let back = shard_payload_for_udp_budget(settled as usize, peer);
|
||||
if back < current
|
||||
&& r.change_tx.send(back as u16).is_ok()
|
||||
&& r.apply_tx.send(back).is_ok()
|
||||
{
|
||||
tracing::info!(peer = %peer, shard_payload = back, was = current,
|
||||
"wire MTU: video re-keyed mid-session back to the standard wire");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A closed connection stops discovering, so a session that ended before the final
|
||||
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
|
||||
@@ -203,12 +490,41 @@ pub(super) fn spawn_watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
// PW7a revert guard for a session that STARTED jumbo and has no re-key channel (the
|
||||
// PyroWave case, and the only reason the session-start grow exists). Nothing can save
|
||||
// this session if the path stops fitting mid-stream — but the NEXT one must not repeat
|
||||
// it, so keep sampling and drop the verdict the moment quinn's blackhole detection or
|
||||
// a re-search says the path shrank. Cheap: one `Connection` handle, one sample per 5 s.
|
||||
// Only for a session that is currently FITTING — one that already failed the check
|
||||
// above has been warned about and had its verdict erased there.
|
||||
if current > mtu1500_shard_payload_for(peer)
|
||||
&& reneg.is_none()
|
||||
&& sealed_datagram_bytes(current) <= settled as usize
|
||||
{
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
let mtu_now = conn.stats().path.current_mtu;
|
||||
if (mtu_now as usize) < sealed_datagram_bytes(current) {
|
||||
jumbo_verdicts().lock().unwrap().remove(&path_key(&conn));
|
||||
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
|
||||
shard_payload = current,
|
||||
"wire MTU: the jumbo path this session started on stopped fitting — this \
|
||||
session cannot be re-keyed (chunk-aligned client parse window), so it \
|
||||
will not recover, but the verdict is cleared and the next connect \
|
||||
starts at the standard wire");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
|
||||
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
|
||||
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
|
||||
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
|
||||
// ack, even though its buffers are statically sized — the rule must not erode.
|
||||
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
|
||||
let (Some(mtu), Some(r)) = (target_wire_mtu, reneg.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
|
||||
@@ -275,34 +591,196 @@ mod tests {
|
||||
|
||||
const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
|
||||
const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
|
||||
/// No jumbo anywhere — what every session that isn't on an opted-in jumbo LAN passes.
|
||||
const NO_JUMBO: JumboStart = JumboStart {
|
||||
target_wire_mtu: None,
|
||||
client_ceiling: 0,
|
||||
live_udp_mtu: 0,
|
||||
proven_udp_budget: None,
|
||||
clamped_udp_budget: None,
|
||||
};
|
||||
/// A 9000-MTU LAN, a modern client, a path proven last session and re-proven live now.
|
||||
fn proven_jumbo() -> JumboStart {
|
||||
JumboStart {
|
||||
target_wire_mtu: Some(9000),
|
||||
client_ceiling: punktfunk_core::config::max_shard_payload() as u16,
|
||||
live_udp_mtu: 8972,
|
||||
proven_udp_budget: Some(8972),
|
||||
clamped_udp_budget: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_when_nothing_known() {
|
||||
assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6));
|
||||
assert_eq!(
|
||||
resolve(None, None, NO_JUMBO, V4),
|
||||
mtu1500_shard_payload_for(V4)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(None, None, NO_JUMBO, V6),
|
||||
mtu1500_shard_payload_for(V6)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_learned() {
|
||||
// 1280 wire − 28 IP/UDP − 64 header/crypto = 1188.
|
||||
assert_eq!(resolve(Some(1280), Some(1472), V4), 1188);
|
||||
assert_eq!(resolve(Some(1280), Some(1472), NO_JUMBO, V4), 1188);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_budget_clamps() {
|
||||
// A WARP-shaped path: 1280-byte UDP budget → 1280 − 64 = 1216.
|
||||
assert_eq!(resolve(None, Some(1280), V4), 1216);
|
||||
assert_eq!(resolve(None, Some(1280), NO_JUMBO, V4), 1216);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_at_or_above_ceiling_is_the_default_wire() {
|
||||
assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(
|
||||
resolve(None, Some(1472), NO_JUMBO, V4),
|
||||
mtu1500_shard_payload_for(V4)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(None, Some(2000), NO_JUMBO, V4),
|
||||
mtu1500_shard_payload_for(V4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_full_mtu_is_the_default_wire_both_families() {
|
||||
assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6));
|
||||
assert_eq!(
|
||||
resolve(Some(1500), None, NO_JUMBO, V4),
|
||||
mtu1500_shard_payload_for(V4)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(Some(1500), None, NO_JUMBO, V6),
|
||||
mtu1500_shard_payload_for(V6)
|
||||
);
|
||||
}
|
||||
|
||||
/// The happy path, both families: 9000 − 28 (IPv4) − 64 = 8908, and 9000 − 48 − 64 = 8888.
|
||||
#[test]
|
||||
fn proven_and_reproven_path_starts_jumbo() {
|
||||
assert_eq!(jumbo_session_start(proven_jumbo(), V4), Some(8908));
|
||||
let mut v6 = proven_jumbo();
|
||||
v6.live_udp_mtu = 8952;
|
||||
v6.proven_udp_budget = Some(8952);
|
||||
assert_eq!(jumbo_session_start(v6, V6), Some(8888));
|
||||
// …and it is what `resolve` returns, ahead of the env branch that would clamp a
|
||||
// >1500 `PUNKTFUNK_WIRE_MTU` back down to the family default.
|
||||
assert_eq!(resolve(Some(9000), None, proven_jumbo(), V4), 8908);
|
||||
}
|
||||
|
||||
/// THE guard: the laptop that proved jumbo on the wired LAN and came back on a 1500-MTU
|
||||
/// link. The memory still says jumbo; the live connection says otherwise; the live one
|
||||
/// wins, every time. This is what makes the grow as safe as the clamp.
|
||||
#[test]
|
||||
fn a_remembered_verdict_never_grows_without_a_live_reproof() {
|
||||
let mut moved = proven_jumbo();
|
||||
moved.live_udp_mtu = 1472; // a clean 1500-MTU path, freshly measured
|
||||
assert_eq!(jumbo_session_start(moved, V4), None);
|
||||
assert_eq!(
|
||||
resolve(None, None, moved, V4),
|
||||
mtu1500_shard_payload_for(V4)
|
||||
);
|
||||
// Not even one byte of headroom short of the sealed target is enough.
|
||||
let mut nearly = proven_jumbo();
|
||||
nearly.live_udp_mtu = 8971;
|
||||
assert_eq!(jumbo_session_start(nearly, V4), None);
|
||||
}
|
||||
|
||||
/// …and the mirror: a live-proven path with no prior verdict still starts at the default.
|
||||
/// Both halves are required, so a single fluke on either side cannot seal a jumbo wire.
|
||||
#[test]
|
||||
fn a_live_proof_alone_does_not_grow() {
|
||||
let mut first_ever = proven_jumbo();
|
||||
first_ever.proven_udp_budget = None;
|
||||
assert_eq!(jumbo_session_start(first_ever, V4), None);
|
||||
let mut weak_memory = proven_jumbo();
|
||||
weak_memory.proven_udp_budget = Some(1472);
|
||||
assert_eq!(jumbo_session_start(weak_memory, V4), None);
|
||||
}
|
||||
|
||||
/// The two memories are keyed differently (clamp: peer; verdict: route), so they can
|
||||
/// disagree. When they do, the one that keeps datagrams small wins.
|
||||
#[test]
|
||||
fn a_constrained_path_clamp_vetoes_the_grow() {
|
||||
let mut contradicted = proven_jumbo();
|
||||
contradicted.clamped_udp_budget = Some(1280);
|
||||
assert_eq!(jumbo_session_start(contradicted, V4), None);
|
||||
// A clamp that is itself at or above the sealed target isn't contrary evidence.
|
||||
let mut roomy = proven_jumbo();
|
||||
roomy.clamped_udp_budget = Some(8972);
|
||||
assert_eq!(jumbo_session_start(roomy, V4), Some(8908));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_operator_opt_in_nothing_grows() {
|
||||
let mut no_optin = proven_jumbo();
|
||||
no_optin.target_wire_mtu = None;
|
||||
assert_eq!(jumbo_session_start(no_optin, V4), None);
|
||||
}
|
||||
|
||||
/// A legacy client (no `Hello::max_shard_payload`) is never handed a geometry it did not
|
||||
/// advertise, and a client whose ceiling lands under the family default is left alone
|
||||
/// rather than being "grown" to something smaller.
|
||||
#[test]
|
||||
fn the_client_ceiling_is_binding() {
|
||||
let mut legacy = proven_jumbo();
|
||||
legacy.client_ceiling = 0;
|
||||
assert_eq!(jumbo_session_start(legacy, V4), None);
|
||||
let mut small = proven_jumbo();
|
||||
small.client_ceiling = 1408;
|
||||
assert_eq!(jumbo_session_start(small, V4), None);
|
||||
// A ceiling between the default and the path target caps the grow — and the proof
|
||||
// then only has to cover the SMALLER sealed size.
|
||||
let mut capped = proven_jumbo();
|
||||
capped.client_ceiling = 4000;
|
||||
assert_eq!(jumbo_session_start(capped, V4), Some(4000));
|
||||
}
|
||||
|
||||
/// Every shard payload the grow can produce is even (Leopard FEC splits shards in halves)
|
||||
/// and fits the receive ceiling every client sizes its buffers from.
|
||||
#[test]
|
||||
fn grown_shards_stay_even_and_inside_the_receive_ceiling() {
|
||||
for mtu in [2000usize, 4000, 4001, 9000, 9216, 64000] {
|
||||
for peer in [V4, V6] {
|
||||
let Some(t) = jumbo_target(Some(mtu), u16::MAX, peer) else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(t % 2, 0, "odd shard for mtu {mtu}");
|
||||
assert!(t <= punktfunk_core::config::max_shard_payload());
|
||||
assert!(t > mtu1500_shard_payload_for(peer));
|
||||
assert!(
|
||||
sealed_datagram_bytes(t) <= punktfunk_core::packet::MAX_DATAGRAM_BYTES,
|
||||
"sealed datagram overflows the receive ceiling at mtu {mtu}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Below the family default there is nothing to grow to.
|
||||
assert_eq!(jumbo_target(Some(1500), u16::MAX, V4), None);
|
||||
assert_eq!(jumbo_target(None, u16::MAX, V4), None);
|
||||
}
|
||||
|
||||
/// A path is a (local interface, peer) pair, not a peer: the same client reached over the
|
||||
/// host's other NIC is a different route with a different MTU.
|
||||
#[test]
|
||||
fn the_verdict_key_separates_routes_to_the_same_peer() {
|
||||
let over_10g = PathKey {
|
||||
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
|
||||
peer: V4,
|
||||
};
|
||||
let over_wifi = PathKey {
|
||||
local: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
|
||||
peer: V4,
|
||||
};
|
||||
assert_ne!(over_10g, over_wifi);
|
||||
assert_ne!(
|
||||
over_10g,
|
||||
PathKey {
|
||||
local: None,
|
||||
peer: V4
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,12 +193,29 @@ impl SessionPlan {
|
||||
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
|
||||
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
|
||||
// it looks like an unexplained fps ceiling if you don't know it happened.
|
||||
tracing::warn!(
|
||||
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy GPU \
|
||||
capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; expect a \
|
||||
lower fps ceiling than 4:2:0 at this mode (set PUNKTFUNK_ZEROCOPY=1 for the \
|
||||
GPU 4:4:4 convert)"
|
||||
);
|
||||
//
|
||||
// Name the SESSION's codec, not the backend the gate is named after. The gate
|
||||
// keys on `linux_zero_copy_is_vaapi()`, which reads the host-global encoder pref
|
||||
// — so a per-session PyroWave negotiation on an NVENC/auto host lands here and
|
||||
// was told it was "on the NVENC path", which is false in every particular: the
|
||||
// wavelet encoder never touches NVENC, never swscales to YUV444P, and what it
|
||||
// actually loses is the raw-dmabuf passthrough its whole design assumes.
|
||||
if self.codec == crate::encode::Codec::PyroWave {
|
||||
tracing::warn!(
|
||||
"4:4:4 PyroWave session with PUNKTFUNK_ZEROCOPY off: zero-copy GPU \
|
||||
capture DISABLED — the wavelet encoder loses its raw-dmabuf passthrough \
|
||||
and every frame becomes a full-resolution CPU readback plus an upload \
|
||||
into its own Vulkan device; expect a materially lower fps ceiling (set \
|
||||
PUNKTFUNK_ZEROCOPY=1 to restore the passthrough)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy \
|
||||
GPU capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; \
|
||||
expect a lower fps ceiling than 4:2:0 at this mode (set \
|
||||
PUNKTFUNK_ZEROCOPY=1 for the GPU 4:4:4 convert)"
|
||||
);
|
||||
}
|
||||
}
|
||||
gpu && !force_cpu_for_nvenc_444
|
||||
};
|
||||
|
||||
@@ -48,6 +48,17 @@ pub struct Options {
|
||||
pub out: PathBuf,
|
||||
/// Also round-trip every AU through a `punktfunk_core` host→client loopback and verify.
|
||||
pub loopback: bool,
|
||||
/// PyroWave datagram-aligned packetization at this shard payload
|
||||
/// ([`Encoder::set_wire_chunking`], plan §4.4) — what a real session passes from its
|
||||
/// negotiated `shard_payload`. `None` = the dense one-packet-per-AU shape.
|
||||
///
|
||||
/// This is also the switch that makes the STREAMED-AU wire reachable from the spike: with
|
||||
/// it set and `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` armed, the encoder's `poll_chunk` hands the
|
||||
/// AU out in window-aligned pieces and the loopback seals them through
|
||||
/// `begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` — the same path a
|
||||
/// `VIDEO_CAP_STREAMED_AU` client drives. Without it there is no way to exercise PW6 end to
|
||||
/// end outside a real client session.
|
||||
pub wire_chunk: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn run(opts: Options) -> Result<()> {
|
||||
@@ -114,9 +125,21 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
refresh_hz: opts.fps,
|
||||
})
|
||||
.context("create virtual output")?;
|
||||
// `resolve` is the shared GameStream/spike constructor and hard-codes `pyrowave: false`
|
||||
// (GameStream never negotiates it). The spike DOES know its codec, and on Linux that
|
||||
// flag is what puts the capture on the raw-dmabuf passthrough
|
||||
// (`ZeroCopyPolicy::pyrowave_session`, set from the same comparison in
|
||||
// `session_plan::output_format`). Left false, `--codec pyrowave` encoded PyroWave off a
|
||||
// capture negotiated for somebody else, and the only way to exercise the real path was
|
||||
// the host-global `PUNKTFUNK_ENCODER=pyrowave` lever — which ALSO flips
|
||||
// `backend_is_vaapi`, so it cannot reproduce a per-session PyroWave negotiation on an
|
||||
// auto/NVENC host at all. That is precisely the configuration PW2 exists for.
|
||||
let mut want =
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu());
|
||||
want.pyrowave = opts.codec == Codec::PyroWave;
|
||||
capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||
want,
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
compositor == crate::vdisplay::Compositor::Kwin,
|
||||
)
|
||||
@@ -155,6 +178,18 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
)
|
||||
.context("open encoder")?;
|
||||
|
||||
// Datagram-aligned packetization (§4.4) — and, with the PW6 knob armed, the gate that makes
|
||||
// `supports_chunked_poll()` true so the drain below takes the streamed-AU path.
|
||||
if let Some(c) = opts.wire_chunk {
|
||||
encoder.set_wire_chunking(c);
|
||||
tracing::info!(
|
||||
shard_payload = c,
|
||||
chunked_poll = encoder.supports_chunked_poll(),
|
||||
"spike: wire chunking on (chunked_poll=false means PUNKTFUNK_PYROWAVE_STREAMED_AU \
|
||||
is not armed — the AU still goes out whole)"
|
||||
);
|
||||
}
|
||||
|
||||
let mut sink = BufWriter::new(
|
||||
File::create(&opts.out).with_context(|| format!("create {}", opts.out.display()))?,
|
||||
);
|
||||
@@ -194,6 +229,12 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
out = %opts.out.display(),
|
||||
elapsed_s = format!("{elapsed:.2}"),
|
||||
encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)),
|
||||
// 0 = the whole-AU drain; > encoded = the streamed drain actually cut AUs into pieces.
|
||||
chunks = stats.chunks,
|
||||
chunks_per_au = format!(
|
||||
"{:.1}",
|
||||
stats.chunks as f64 / (stats.encoded.max(1)) as f64
|
||||
),
|
||||
"spike capture→encode→file complete"
|
||||
);
|
||||
|
||||
@@ -217,6 +258,9 @@ struct Stats {
|
||||
encoded: u64,
|
||||
keyframes: u64,
|
||||
bytes_out: u64,
|
||||
/// Streamed-AU drain only: total chunks polled across all AUs (1 per AU means the cut never
|
||||
/// engaged — the knob is off or the AU fits one chunk).
|
||||
chunks: u64,
|
||||
}
|
||||
|
||||
fn drain_encoder(
|
||||
@@ -225,6 +269,12 @@ fn drain_encoder(
|
||||
mut lb: Option<&mut Loopback>,
|
||||
stats: &mut Stats,
|
||||
) -> Result<()> {
|
||||
// Streamed-AU drain (PW6): the encoder hands the finished AU out in shard-aligned pieces and
|
||||
// the loopback seals each piece as it arrives, exactly as the native host's send thread does.
|
||||
// Re-queried per drain, never cached — the trait's contract.
|
||||
if encoder.supports_chunked_poll() {
|
||||
return drain_encoder_chunked(encoder, sink, lb, stats);
|
||||
}
|
||||
while let Some(au) = encoder.poll().context("encoder poll")? {
|
||||
sink.write_all(&au.data).context("write AU to file")?;
|
||||
stats.encoded += 1;
|
||||
@@ -239,6 +289,49 @@ fn drain_encoder(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The streamed-AU drain. Each chunk is sealed into the open wire frame the moment it is polled;
|
||||
/// the concatenation is kept only so the completed AU can still be written to the file sink and
|
||||
/// byte-compared against what the client reassembled — which is the point of the leg: it proves
|
||||
/// the chunks the encoder cut, sealed through the sentinel-block wire, reassemble to EXACTLY the
|
||||
/// AU `poll()` would have produced.
|
||||
fn drain_encoder_chunked(
|
||||
encoder: &mut dyn Encoder,
|
||||
sink: &mut impl Write,
|
||||
mut lb: Option<&mut Loopback>,
|
||||
stats: &mut Stats,
|
||||
) -> Result<()> {
|
||||
let mut whole: Vec<u8> = Vec::new();
|
||||
let mut chunks = 0u32;
|
||||
while let Some(c) = encoder.poll_chunk().context("encoder poll_chunk")? {
|
||||
if c.first {
|
||||
whole.clear();
|
||||
chunks = 0;
|
||||
if let Some(lb) = lb.as_deref_mut() {
|
||||
lb.streamed_begin(c.pts_ns, c.keyframe)?;
|
||||
}
|
||||
}
|
||||
whole.extend_from_slice(&c.data);
|
||||
chunks += 1;
|
||||
if let Some(lb) = lb.as_deref_mut() {
|
||||
lb.streamed_chunk(&c.data)?;
|
||||
}
|
||||
if !c.last {
|
||||
continue;
|
||||
}
|
||||
sink.write_all(&whole).context("write AU to file")?;
|
||||
stats.encoded += 1;
|
||||
stats.bytes_out += whole.len() as u64;
|
||||
stats.chunks += chunks as u64;
|
||||
if c.keyframe {
|
||||
stats.keyframes += 1;
|
||||
}
|
||||
if let Some(lb) = lb.as_deref_mut() {
|
||||
lb.streamed_finish(&whole)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A host↔client `punktfunk_core` pair over a lossless in-process loopback. Each encoded AU is
|
||||
/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the
|
||||
/// original — exercising the core on real encoder output (the spike "feed into a Session" goal).
|
||||
@@ -249,6 +342,14 @@ struct Loopback {
|
||||
recovered: u64,
|
||||
mismatches: u64,
|
||||
bytes: u64,
|
||||
/// The streamed AU currently open (PW6). `Some` strictly between `streamed_begin` and
|
||||
/// `streamed_finish`, mirroring the native send thread's `StreamedOpen`.
|
||||
open: Option<punktfunk_core::packet::StreamedAu>,
|
||||
/// Wire frame index for the streamed path. `submit_frame` uses the packetizer's internal
|
||||
/// counter and `begin_streamed_frame_at` takes an explicit one; a session must use ONE
|
||||
/// numbering style, and the spike never mixes them (`supports_chunked_poll()` is constant
|
||||
/// for a PyroWave session, so every AU takes the same route).
|
||||
next_index: u32,
|
||||
}
|
||||
|
||||
impl Loopback {
|
||||
@@ -265,9 +366,101 @@ impl Loopback {
|
||||
recovered: 0,
|
||||
mismatches: 0,
|
||||
bytes: 0,
|
||||
open: None,
|
||||
next_index: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a streamed AU on the wire (PW6). The client side needs no opt-in: a streamed frame
|
||||
/// completes exactly like a whole one and is handed up as a single `Frame` — which is the
|
||||
/// finding this leg exists to demonstrate rather than assert.
|
||||
fn streamed_begin(&mut self, pts_ns: u64, keyframe: bool) -> Result<()> {
|
||||
if self.open.is_some() {
|
||||
return Err(anyhow!(
|
||||
"streamed AU still open at begin — a previous AU never sent its `last` chunk"
|
||||
));
|
||||
}
|
||||
let mut flags = FLAG_PIC as u32;
|
||||
if keyframe {
|
||||
flags |= FLAG_SOF as u32;
|
||||
}
|
||||
let idx = self.next_index;
|
||||
self.next_index = self.next_index.wrapping_add(1);
|
||||
self.open = Some(
|
||||
self.host
|
||||
.begin_streamed_frame_at(pts_ns, flags, idx)
|
||||
.map_err(|e| anyhow!("begin_streamed_frame_at: {e:?}"))?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seal + send one encoder chunk. The returned batch is often EMPTY (the sealer buffers
|
||||
/// until a whole FEC block accumulates) — that is the normal case, not an error.
|
||||
fn streamed_chunk(&mut self, data: &[u8]) -> Result<()> {
|
||||
let au = self
|
||||
.open
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("streamed chunk with no open AU"))?;
|
||||
let wires = self
|
||||
.host
|
||||
.seal_streamed_chunk(au, data, false)
|
||||
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
|
||||
self.send(wires)
|
||||
}
|
||||
|
||||
/// Close the AU (final block carries the real totals) and verify what the client got.
|
||||
fn streamed_finish(&mut self, expect: &[u8]) -> Result<()> {
|
||||
let au = self
|
||||
.open
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("streamed finish with no open AU"))?;
|
||||
let wires = self
|
||||
.host
|
||||
.seal_streamed_finish(au)
|
||||
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
|
||||
self.send(wires)?;
|
||||
self.submitted += 1;
|
||||
self.bytes += expect.len() as u64;
|
||||
self.verify(expect)
|
||||
}
|
||||
|
||||
fn send(&mut self, wires: Vec<Vec<u8>>) -> Result<()> {
|
||||
if wires.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
self.host
|
||||
.send_sealed(&refs)
|
||||
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
|
||||
drop(refs);
|
||||
self.host.reclaim_wires(wires);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain whatever the client can now reassemble and byte-compare it to `expect`.
|
||||
fn verify(&mut self, expect: &[u8]) -> Result<()> {
|
||||
loop {
|
||||
match self.client.poll_frame() {
|
||||
Ok(frame) => {
|
||||
self.recovered += 1;
|
||||
if frame.data != expect {
|
||||
self.mismatches += 1;
|
||||
tracing::warn!(
|
||||
recovered = self.recovered,
|
||||
got = frame.data.len(),
|
||||
expected = expect.len(),
|
||||
complete = frame.complete,
|
||||
"loopback AU mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(punktfunk_core::PunktfunkError::NoFrame) => break,
|
||||
Err(e) => return Err(anyhow!("client poll_frame: {e:?}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn submit(&mut self, au: &EncodedFrame) -> Result<()> {
|
||||
let mut flags = FLAG_PIC as u32;
|
||||
if au.keyframe {
|
||||
|
||||
@@ -17,7 +17,38 @@ VK_ERROR_NOT_PERMITTED_KHR so a refused class NEVER regresses the encoder. Gated
|
||||
NOTE: on an RTX 4090 / Windows / WDDM this did not reduce the spikes (the graphics-vs-compute
|
||||
preemption granularity is the wall) — kept because it is correct, harmless (graceful fallback), and
|
||||
may help other GPUs/drivers. Reduce the encode's GPU cost (4:2:0/8-bit) or use H.265 for a
|
||||
GPU-saturated game.
|
||||
GPU-saturated game. **That measurement is Windows/WDDM and does NOT transfer to Linux** — a
|
||||
different driver stack with a different preemption model.
|
||||
|
||||
MEASURED ON LINUX/NVIDIA 2026-08-08, and it comes out the OTHER WAY: the elevated queue DOES cut
|
||||
the tail. RTX 5070 Ti (driver 610.57.04), GRID 2 benchmark loop saturating the GPU at 54-87 %,
|
||||
PyroWave 1080p, same binary in both arms (the only difference is CAP_SYS_NICE, i.e. whether the
|
||||
class is granted at all), steady-state windows of 30 frames:
|
||||
|
||||
arm p50 p99 worst frame
|
||||
default priority (refused) ~2.6 ms ~6.4 ms 9.5 ms
|
||||
REALTIME granted ~3.2 ms ~4.4 ms 5.4 ms (repeat: p50 ~3.35, p99 ~4.8)
|
||||
|
||||
So on this stack the priority class buys a materially tighter TAIL — p99 down ~30 %, worst frame
|
||||
roughly halved — at the cost of ~0.6 ms on the median. For a streaming encoder that is the right
|
||||
side of the trade: the tail is what shows up as a visible hitch. Do NOT delete this patch on the
|
||||
strength of the RTX 4090/WDDM result above; the two stacks disagree.
|
||||
|
||||
Caveats, so the number is not over-read: the arms were not interleaved and the background game
|
||||
load drifted between them, capture was frame-starved (~2.5 fps) so this measures encode latency
|
||||
under contention rather than a full-rate stream, and it is two granted runs against one refused
|
||||
run. The direction was consistent across all 25 measurement windows.
|
||||
|
||||
NOTE 2 — WHERE THIS PATCH IS ACTUALLY LIVE. It is gated `if (!inherit_info)`, and only the WINDOWS
|
||||
path leaves `inherit_info` null: `crates/pf-encode/src/enc/windows/pyrowave.rs` calls
|
||||
`pyrowave_create_device_by_compat`, so Granite builds the device itself and this block runs.
|
||||
**On LINUX it has never done anything.** `crates/pf-encode/src/enc/linux/pyrowave.rs::open_inner`
|
||||
passes its own instance/device create-infos into `pyrowave_device_create_info`, Granite's
|
||||
`MyDeviceFactory::get_existing_create_info()` returns them, `create_device` takes the inherit
|
||||
branch, and the whole block above is skipped. The Linux request is therefore wired natively in
|
||||
**`crates/pf-encode/src/enc/linux/pyrowave.rs`** (search `queue_priority_candidates`), which
|
||||
implements the SAME env grammar and the SAME downgrade ladder so one knob means one thing on both
|
||||
platforms. If you change the grammar here, change it there in the same commit.
|
||||
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/context.cpp
|
||||
index 5257fc33..479eeded 100644
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
Encoder wire-sequence override — PUNKTFUNK LOCAL PATCH.
|
||||
|
||||
Not upstream. Exposes `Encoder::set_next_sequence(uint32_t)` (and a
|
||||
`pyrowave_encoder_set_next_sequence` C entry) so the caller can stamp the 3-bit wire sequence
|
||||
counter itself instead of relying on the encoder object's private one.
|
||||
|
||||
WHY IT EXISTS. PyroWave's `Encoder` structurally cannot hold two frames in flight: `Encoder::Impl`
|
||||
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
|
||||
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them — an image barrier
|
||||
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout (a written promise nothing else is reading it)
|
||||
plus three `fill_buffer` clears. Two `encode()` calls recorded into two command buffers and
|
||||
submitted to the same queue have no execution dependency in Vulkan, so encode N+1's DWT would
|
||||
overwrite the bands and zero the RDO buckets while encode N's block packing still reads them.
|
||||
|
||||
So overlapping frames means TWO encoder handles on one device, alternated — which is fine for
|
||||
every resource above, because each handle gets its own. It is NOT fine for `sequence_count`, which
|
||||
also lives on `Impl` and is stamped into every block header (pyrowave_encoder.cpp `packing_push`).
|
||||
Two alternating handles each count 1,2,3... independently, so the wire sees 1,1,2,2,3,3...
|
||||
|
||||
That is silently fatal on the decode side. `pyrowave_decoder.cpp` computes
|
||||
`diff = (hdr.sequence - last_seq) & 0x7` and treats `restart = diff != 0`, so a REPEATED value
|
||||
reads as "more blocks of the same frame": `clear()` never runs, `decoded_frame_for_current_sequence`
|
||||
stays true, and every second frame is swallowed. The symptom is "it works, just at half rate, with
|
||||
occasional mixed-frame blocks" — the kind of failure that passes a smoke test. It would hit every
|
||||
client, since pf-client-core and the Apple Metal hand-port parse the same field.
|
||||
|
||||
WHAT IT DOES. `set_next_sequence(seq)` stores `(seq - 1) & SequenceCountMask`, because
|
||||
`Impl::encode` pre-increments before stamping — the setter's contract is about the next ENCODE, not
|
||||
the next store. The Rust side keeps one monotonic counter across both handles and calls this before
|
||||
each encode, so the wire sequence increments by exactly 1 mod 8 regardless of which handle produced
|
||||
the frame.
|
||||
|
||||
INERT WHEN UNUSED. Nothing calls it unless the caller does, so the single-handle paths — including
|
||||
the whole Windows backend — behave exactly as before. No `.def` change is needed: the C API is
|
||||
built as a static archive (crates/pyrowave-sys/CMakeLists.txt).
|
||||
|
||||
Upstream status: not reported. It is a hook for a use case upstream explicitly designed against
|
||||
("For low-latency use cases, overlapping frames in encode is meaningless due to latency and the
|
||||
encoder is so fast anyway" — pyrowave.h). That reasoning holds at 1080p60 and stops holding at 4K
|
||||
or under a GPU-bound game, which is what PW5 measured.
|
||||
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h b/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
|
||||
index fc0d5834..aeb22ffc 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave.h
|
||||
@@ -476,6 +476,19 @@ PYROWAVE_PUBLIC_API pyrowave_result
|
||||
pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, size_t packet_boundary,
|
||||
size_t *out_packets, void *bitstream, size_t size);
|
||||
|
||||
+// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
|
||||
+// The wire sequence counter is 3 bits (PyroWave::SequenceCountMask, pyrowave_common.hpp);
|
||||
+// exported here so callers mask with the codec's own value instead of a copied literal.
|
||||
+#define PYROWAVE_SEQUENCE_MASK 0x7u
|
||||
+
|
||||
+// Overrides the 3-bit wire sequence counter the NEXT encode will stamp into every block header.
|
||||
+// The counter lives on the encoder object, so a caller that alternates TWO encoders to overlap
|
||||
+// frames emits 1,1,2,2,3,3... and the decoder — which restarts a frame only when the value
|
||||
+// CHANGES — reads the repeat as more blocks of the same frame and silently swallows every second
|
||||
+// frame. Stamp a single monotonic counter across the handles with this. Value is masked to 3 bits.
|
||||
+PYROWAVE_PUBLIC_API pyrowave_result
|
||||
+pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence);
|
||||
+
|
||||
// Implementation ensures GPU is idle before destroying objects.
|
||||
PYROWAVE_PUBLIC_API void
|
||||
pyrowave_encoder_destroy(pyrowave_encoder encoder);
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
index 985cd0a9..fcd7d6f8 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
@@ -1196,6 +1196,17 @@ pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, s
|
||||
return PYROWAVE_SUCCESS;
|
||||
}
|
||||
|
||||
+// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
|
||||
+pyrowave_result
|
||||
+pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence)
|
||||
+{
|
||||
+ Util::set_thread_logging_interface(&null_logger);
|
||||
+ if (!encoder)
|
||||
+ return PYROWAVE_ERROR_GENERIC;
|
||||
+ encoder->encoder.set_next_sequence(sequence);
|
||||
+ return PYROWAVE_SUCCESS;
|
||||
+}
|
||||
+
|
||||
void pyrowave_encoder_destroy(pyrowave_encoder encoder)
|
||||
{
|
||||
auto *device = encoder->device;
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
index ad4e9746..f23717f3 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
@@ -1230,6 +1230,14 @@ bool Encoder::encode(CommandBuffer &cmd, const ViewBuffers &views, const Bitstre
|
||||
return impl->encode(cmd, views, buffers);
|
||||
}
|
||||
|
||||
+// PUNKTFUNK: see the declaration in pyrowave_encoder.hpp. Impl::encode PRE-increments
|
||||
+// (sequence_count = (sequence_count + 1) & mask before stamping), so store one less than the value
|
||||
+// the caller wants stamped — the setter's contract is about the next ENCODE, not the next store.
|
||||
+void Encoder::set_next_sequence(uint32_t sequence)
|
||||
+{
|
||||
+ impl->sequence_count = (sequence - 1) & SequenceCountMask;
|
||||
+}
|
||||
+
|
||||
const Vulkan::ImageView &Encoder::get_wavelet_band(int component, int level)
|
||||
{
|
||||
return *impl->component_layer_views[component][level];
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
|
||||
index a65447d5..8c0ef0d0 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.hpp
|
||||
@@ -37,6 +37,12 @@ public:
|
||||
bool init(Vulkan::Device *device, int width, int height, ChromaSubsampling chroma);
|
||||
bool encode(Vulkan::CommandBuffer &cmd, const ViewBuffers &views, const BitstreamBuffers &buffers);
|
||||
|
||||
+ // PUNKTFUNK: override the 3-bit wire sequence counter the NEXT encode will stamp.
|
||||
+ // The counter is per-Encoder, so alternating two encoder objects to overlap frames emits
|
||||
+ // 1,1,2,2,3,3... and the decoder reads a repeated value as "more blocks of the same frame".
|
||||
+ // See crates/pyrowave-sys/patches/0007-encoder-sequence-override.patch.
|
||||
+ void set_next_sequence(uint32_t sequence);
|
||||
+
|
||||
// Debug hackery
|
||||
const Vulkan::ImageView &get_wavelet_band(int component, int level);
|
||||
bool encode_pre_transformed(Vulkan::CommandBuffer &cmd, const BitstreamBuffers &buffers, float quant_scale);
|
||||
@@ -46,4 +46,9 @@ upstream:
|
||||
realtime) so the wavelet encode can preempt a GPU-bound game on the shared shader cores. A
|
||||
create loop downgrades on NOT_PERMITTED so a refused class never regresses the encoder. Did
|
||||
not overcome the graphics-vs-compute preemption wall on an RTX 4090 (kept: correct + harmless,
|
||||
may help other HW/drivers).
|
||||
may help other HW/drivers) — that measurement is Windows/WDDM and does not transfer to Linux.
|
||||
GATED ON !inherit_info, so it is LIVE ONLY ON THE WINDOWS PATH (pyrowave_create_device_by_compat,
|
||||
where Granite builds its own device). Linux passes its own create-infos and takes the inherit
|
||||
branch, so this patch is inert there; the Linux request lives natively in
|
||||
crates/pf-encode/src/enc/linux/pyrowave.rs (queue_priority_candidates), with the same grammar
|
||||
and the same downgrade ladder. Change one, change both.
|
||||
|
||||
+13
@@ -476,6 +476,19 @@ PYROWAVE_PUBLIC_API pyrowave_result
|
||||
pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, size_t packet_boundary,
|
||||
size_t *out_packets, void *bitstream, size_t size);
|
||||
|
||||
// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
|
||||
// The wire sequence counter is 3 bits (PyroWave::SequenceCountMask, pyrowave_common.hpp);
|
||||
// exported here so callers mask with the codec's own value instead of a copied literal.
|
||||
#define PYROWAVE_SEQUENCE_MASK 0x7u
|
||||
|
||||
// Overrides the 3-bit wire sequence counter the NEXT encode will stamp into every block header.
|
||||
// The counter lives on the encoder object, so a caller that alternates TWO encoders to overlap
|
||||
// frames emits 1,1,2,2,3,3... and the decoder — which restarts a frame only when the value
|
||||
// CHANGES — reads the repeat as more blocks of the same frame and silently swallows every second
|
||||
// frame. Stamp a single monotonic counter across the handles with this. Value is masked to 3 bits.
|
||||
PYROWAVE_PUBLIC_API pyrowave_result
|
||||
pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence);
|
||||
|
||||
// Implementation ensures GPU is idle before destroying objects.
|
||||
PYROWAVE_PUBLIC_API void
|
||||
pyrowave_encoder_destroy(pyrowave_encoder encoder);
|
||||
|
||||
@@ -1196,6 +1196,17 @@ pyrowave_encoder_packetize(pyrowave_encoder encoder, pyrowave_packet *packets, s
|
||||
return PYROWAVE_SUCCESS;
|
||||
}
|
||||
|
||||
// PUNKTFUNK LOCAL EXTENSION (patches/0007-encoder-sequence-override.patch), not upstream.
|
||||
pyrowave_result
|
||||
pyrowave_encoder_set_next_sequence(pyrowave_encoder encoder, uint32_t sequence)
|
||||
{
|
||||
Util::set_thread_logging_interface(&null_logger);
|
||||
if (!encoder)
|
||||
return PYROWAVE_ERROR_GENERIC;
|
||||
encoder->encoder.set_next_sequence(sequence);
|
||||
return PYROWAVE_SUCCESS;
|
||||
}
|
||||
|
||||
void pyrowave_encoder_destroy(pyrowave_encoder encoder)
|
||||
{
|
||||
auto *device = encoder->device;
|
||||
|
||||
@@ -1230,6 +1230,14 @@ bool Encoder::encode(CommandBuffer &cmd, const ViewBuffers &views, const Bitstre
|
||||
return impl->encode(cmd, views, buffers);
|
||||
}
|
||||
|
||||
// PUNKTFUNK: see the declaration in pyrowave_encoder.hpp. Impl::encode PRE-increments
|
||||
// (sequence_count = (sequence_count + 1) & mask before stamping), so store one less than the value
|
||||
// the caller wants stamped — the setter's contract is about the next ENCODE, not the next store.
|
||||
void Encoder::set_next_sequence(uint32_t sequence)
|
||||
{
|
||||
impl->sequence_count = (sequence - 1) & SequenceCountMask;
|
||||
}
|
||||
|
||||
const Vulkan::ImageView &Encoder::get_wavelet_band(int component, int level)
|
||||
{
|
||||
return *impl->component_layer_views[component][level];
|
||||
|
||||
@@ -37,6 +37,12 @@ public:
|
||||
bool init(Vulkan::Device *device, int width, int height, ChromaSubsampling chroma);
|
||||
bool encode(Vulkan::CommandBuffer &cmd, const ViewBuffers &views, const BitstreamBuffers &buffers);
|
||||
|
||||
// PUNKTFUNK: override the 3-bit wire sequence counter the NEXT encode will stamp.
|
||||
// The counter is per-Encoder, so alternating two encoder objects to overlap frames emits
|
||||
// 1,1,2,2,3,3... and the decoder reads a repeated value as "more blocks of the same frame".
|
||||
// See crates/pyrowave-sys/patches/0007-encoder-sequence-override.patch.
|
||||
void set_next_sequence(uint32_t sequence);
|
||||
|
||||
// Debug hackery
|
||||
const Vulkan::ImageView &get_wavelet_band(int component, int level);
|
||||
bool encode_pre_transformed(Vulkan::CommandBuffer &cmd, const BitstreamBuffers &buffers, float quant_scale);
|
||||
|
||||
@@ -57,6 +57,18 @@ sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see b
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
|
||||
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
|
||||
```
|
||||
|
||||
That is a second group on purpose. It grants write access to the usbip `attach` file, which
|
||||
materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is
|
||||
routinely told to join. Join it only on a machine you trust. Without it, everything else still
|
||||
works and the pad simply arrives as an ordinary Xbox 360 controller.
|
||||
|
||||
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
|
||||
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
|
||||
the classic partial upgrade that breaks Arch boxes. To take several in one go, name them on a
|
||||
|
||||
@@ -126,6 +126,18 @@ ujust add-user-to-input-group
|
||||
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
|
||||
permission, not a client problem.)
|
||||
|
||||
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||
`punktfunk` — `usermod` is fine here, because unlike `input` this group is ours and the sysext
|
||||
creates it on merge:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
```
|
||||
|
||||
It is a separate group on purpose: it gates the usbip `attach` file, which can materialise
|
||||
arbitrary emulated USB hardware, so it is not folded into the group everyone is told to join for
|
||||
gamepads. Skip it and the pad arrives as an ordinary Xbox 360 controller instead.
|
||||
|
||||
## Configure
|
||||
|
||||
The RPM ships a Bazzite-tuned config you can copy as your starting point:
|
||||
|
||||
@@ -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
|
||||
@@ -240,6 +241,7 @@ notes for context.
|
||||
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
|
||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. This needs the `CAP_SYS_NICE` capability, which the packages deliberately do **not** grant: a host holding a capability cannot be identified by KWin and loses desktop streaming entirely (see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority)). The request is therefore refused on a stock install and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. |
|
||||
|
||||
## Diagnostics
|
||||
|
||||
|
||||
@@ -93,6 +93,18 @@ sudo dnf install punktfunk
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
|
||||
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
|
||||
```
|
||||
|
||||
That is a second group on purpose: it grants write access to the usbip `attach` file, which
|
||||
materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is
|
||||
routinely told to join. Join it only on a machine you trust. Skip it and the pad simply arrives as
|
||||
an ordinary Xbox 360 controller.
|
||||
|
||||
Updates later are just `sudo dnf upgrade punktfunk`, followed by
|
||||
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
|
||||
ships the systemd user units, the udev rule, the UDP socket-buffer sysctl tuning, and example
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,9 @@ to one readable line.
|
||||
- **Apple TV** has no keyboard path, and a short press of the Siri Remote's Back button deliberately
|
||||
does nothing — so a controller's B button can't end your session by accident. To leave, **hold
|
||||
Back for about a second and let go**. During a session the remote's touch surface drives the host
|
||||
cursor, a press is a left click, and Play/Pause is a right click.
|
||||
cursor, a press is a left click, and Play/Pause is a right click — **hold Play/Pause** instead and
|
||||
it cycles the [stats overlay](/docs/stats). With a controller in hand, **Select + X** does the
|
||||
same on every Apple client.
|
||||
|
||||
### Leaving with a controller
|
||||
|
||||
@@ -99,6 +101,19 @@ there the client stops opening the controller at all, which is the point of the
|
||||
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
|
||||
watching for the chord either way.
|
||||
|
||||
### Statistics with a controller
|
||||
|
||||
The **Apple** apps reserve a second chord: **Select + X**, which cycles the
|
||||
[stats overlay](/docs/stats) one level each time you complete it. It is for the moment your hands
|
||||
are on a controller and the usual routes aren't — no keyboard for **⌃⌥⇧S**, no free screen for the
|
||||
three-finger tap — and on **Apple TV** it is the only way there with a pad. X is deliberately none
|
||||
of the four leave-chord buttons, so reaching for one chord never trips the other. Both buttons
|
||||
still reach the game; only the overlay changes locally.
|
||||
|
||||
On the **Siri Remote**, **hold Play/Pause** for about half a second instead. A quick tap of that
|
||||
button is still a right click — the click is simply sent when you let go, so the hold has
|
||||
something to be.
|
||||
|
||||
### The guide button (Xbox / PS / Steam) and Quick Access
|
||||
|
||||
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
||||
|
||||
@@ -155,6 +155,12 @@ you; on NixOS the module does steps 1 and 2, and [NixOS](#nixos) above has the u
|
||||
input](/docs/input#pen-and-stylus) both need `/dev/uinput` — then re-login. The exact
|
||||
command differs per distro — see your guide (`usermod -aG input "$USER"`, or `ujust
|
||||
add-user-to-input-group` on Bazzite).
|
||||
|
||||
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||
`punktfunk`: `sudo usermod -aG punktfunk "$USER"`. Your package created that group at install
|
||||
time; it gates the usbip nodes that pad attaches through, and it is separate from `input` on
|
||||
purpose, because writing them can present arbitrary emulated USB hardware. Join it only on a
|
||||
machine you trust — skipping it costs you nothing but that one pad type.
|
||||
2. Put your `host.env` in place, then start the host. Every Linux package ships a systemd **user**
|
||||
unit, so you don't run the host by hand — but that unit reads `~/.config/punktfunk/host.env` and
|
||||
won't start until the file exists. Each package ships a template to copy; your distro and desktop
|
||||
|
||||
@@ -101,6 +101,27 @@ dropped packets.
|
||||
|
||||
The stats overlay shows `pyrowave` as the decode path when the mode is active.
|
||||
|
||||
## Checking the host is really zero-copy
|
||||
|
||||
On a Linux host the CPU fallback mentioned above is not an error — the session still streams, it
|
||||
just pays a full-resolution copy of every frame, which shows up as a lower frame-rate ceiling and
|
||||
higher CPU use rather than as anything obviously broken. The host log states which path a session
|
||||
took, once, when the capture starts:
|
||||
|
||||
```
|
||||
capture pipeline resolved: dmabuf-passthrough → pyrowave
|
||||
```
|
||||
|
||||
`dmabuf-passthrough` is the good one: the compositor's buffer goes straight into the wavelet
|
||||
encoder. `cpu` means the copy is happening, and a second line says why — a compositor that would
|
||||
not allocate a dmabuf, `PUNKTFUNK_ZEROCOPY` set to `0`, or a per-frame fall-through such as the
|
||||
compositor serving shared memory after agreeing to dmabufs. Each distinct reason is logged once per
|
||||
session with a running count, so a persistent downgrade is easy to tell from a hiccup while the
|
||||
display mode settles.
|
||||
|
||||
If you see `cpu` and did not ask for it, check that `PUNKTFUNK_ZEROCOPY` is unset (it defaults to
|
||||
on) and read the accompanying line — it names the cause and the fix.
|
||||
|
||||
## Current limits
|
||||
|
||||
- Linux and Windows hosts; Linux clients (the GTK desktop app and the session client, including
|
||||
|
||||
@@ -205,6 +205,44 @@ the host.
|
||||
If the host answers, it's up. If not, check `journalctl --user -u punktfunk-host` on the host — on
|
||||
a Windows host, run `punktfunk-host service status` from an elevated prompt on the machine itself.
|
||||
|
||||
## GPU scheduling priority
|
||||
|
||||
The host binary carries **no Linux capability**, and on a KDE desktop it must not.
|
||||
|
||||
The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a
|
||||
demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the
|
||||
driver to schedule the encode ahead of the game, and every driver we tested gates that request on
|
||||
`CAP_SYS_NICE`. Version 0.26.0-1 granted it for that reason — and it broke desktop streaming on
|
||||
every KDE box, so 0.26.0-2 takes it away again. The other codecs use a separate video engine on the
|
||||
GPU and were never affected.
|
||||
|
||||
The two cannot coexist. To hand the host its virtual display, KWin first has to work out *which*
|
||||
program is asking, which it does by reading the connecting process's `/proc/<pid>/exe` and matching
|
||||
it against the `.desktop` file the packages install. Linux refuses that read for any process holding
|
||||
a capability the reader does not also hold — and KWin holds none. So a host with `CAP_SYS_NICE` is a
|
||||
host KWin cannot identify, and every session fails with:
|
||||
|
||||
```
|
||||
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
|
||||
```
|
||||
|
||||
which looks exactly like a missing `.desktop` file and cannot be fixed by reinstalling. Moving the
|
||||
grant into the systemd unit does not help either — same capability, same refused read.
|
||||
|
||||
If you are on 0.26.0-1, update. On the Bazzite image the `/usr` is read-only, so the only repair is
|
||||
the next image (`sudo punktfunk-sysext update`). Elsewhere you can clear it by hand:
|
||||
|
||||
```sh
|
||||
getcap /usr/bin/punktfunk-host # prints nothing when correct
|
||||
sudo setcap -r /usr/bin/punktfunk-host # clear it, then restart the host
|
||||
```
|
||||
|
||||
Losing the capability costs frame pacing under a GPU-bound game and nothing else — the host asks for
|
||||
the elevated priority, is refused, and encodes at the normal one. `PYROWAVE_QUEUE_PRIORITY=off`
|
||||
stops it asking at all. If you stream only with gamescope (Steam Gaming Mode) you can grant the
|
||||
capability yourself and keep the pacing, at the cost of desktop streaming; gamescope has no such
|
||||
identity check.
|
||||
|
||||
## Stopping and removing
|
||||
|
||||
After a Linux package update the user service keeps running the old binary until it's restarted, and
|
||||
|
||||
@@ -45,6 +45,13 @@ in-stream:
|
||||
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
|
||||
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
|
||||
| Android · iPhone | a **three-finger tap** |
|
||||
| Apple TV | **hold Play/Pause** on the Siri Remote |
|
||||
| Any Apple client, controller in hand | **Select + X** |
|
||||
|
||||
**Select + X** is there for the times your hands are on a controller and the other routes aren't:
|
||||
no keyboard for the combo, no free screen for the tap. On an **Apple TV** it is the only one of
|
||||
the two you can reach with a game controller, and holding **Play/Pause** is the equivalent on the
|
||||
Siri Remote — a *tap* on that button still right-clicks, only the hold cycles the overlay.
|
||||
|
||||
**Ctrl+Alt+Shift+S** is one of a small set of shortcuts a stream reserves; the others — release
|
||||
captured input, switch mouse mode, disconnect, mute the microphone — are in
|
||||
|
||||
@@ -74,8 +74,9 @@ It is idempotent — safe to re-run. In one pass it:
|
||||
[plugin store](/docs/plugins) works out of the box — the runner service itself stays opt-in),
|
||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
||||
seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
||||
and adds you to the `input` group (virtual gamepads) **and the `punktfunk` group** (the usbip
|
||||
nodes **native Steam Deck controller passthrough** attaches through — creating that group if it
|
||||
does not exist yet), seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
||||
atomic-update keep list** so OS updates carry it over — the installer asks for your `sudo`
|
||||
password **first, before the long build**, so you can authorise once and walk away,
|
||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||
@@ -102,7 +103,8 @@ When it finishes it prints the web-console URL and how to pair.
|
||||
> surface at all.
|
||||
|
||||
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
|
||||
> on a fresh session, and the new `input` group (native Steam Deck controller passthrough) only takes
|
||||
> on a fresh session, and the new `input` and `punktfunk` groups (native Steam Deck controller
|
||||
> passthrough) only take
|
||||
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
|
||||
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
|
||||
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
|
||||
@@ -127,6 +129,13 @@ The installer generates a random console login password (printed at the end of s
|
||||
to `~/.config/punktfunk/web.env`. To read it back or set your own, see
|
||||
[The Web Console](/docs/web-console#login-password).
|
||||
|
||||
> **Installed before 0.25.0? Rotate that password once.** Older versions of the script created
|
||||
> `web.env` at the account's default umask, so the console password and session secret sat on disk
|
||||
> world-readable — any local account could read them. Re-running `install.sh` or `update.sh` now
|
||||
> tightens the file to `0600` and tells you it did, but a chmod cannot un-leak a secret that was
|
||||
> already readable. Change `PUNKTFUNK_UI_PASSWORD` in `~/.config/punktfunk/web.env`, then
|
||||
> `systemctl --user restart punktfunk-web`.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
```sh
|
||||
@@ -177,8 +186,11 @@ bash ~/punktfunk/scripts/steamdeck/update.sh --pull
|
||||
```
|
||||
|
||||
Drop `--pull` if you rsync source in yourself. `update.sh` also retrofits anything a newer installer
|
||||
adds — the plugin runner, the HDR gamescope, the atomic-update keep list, the rebuild check — onto an
|
||||
older install.
|
||||
adds — the plugin runner, the HDR gamescope, the atomic-update keep list, the rebuild check, the
|
||||
`punktfunk` group, and the `0600`/`0700` permissions on `~/.config/punktfunk` — onto an older
|
||||
install. You do **not** need to run the group or firewall commands from the release notes by hand on
|
||||
a Deck: the group is the script's job, and stock SteamOS runs no firewall for the port note to apply
|
||||
to. Rotating the console password after a pre-0.25.0 install is the one thing still on you (above).
|
||||
|
||||
> **This install follows the canary channel.** An on-device source build tracks `main`, not stable
|
||||
> `vX.Y.Z` releases, so the console offers you the newest `main` build. See
|
||||
@@ -247,7 +259,9 @@ rm -rf ~/.config/punktfunk
|
||||
And the installer may have seeded a KDE RemoteDesktop portal grant at
|
||||
`~/.local/share/flatpak/db/kde-authorized` (only if you had none); remove that file if nothing
|
||||
else on the device relies on it. Your `input` group membership is harmless to keep — drop it with
|
||||
`sudo gpasswd -d "$USER" input` if you'd rather not.
|
||||
`sudo gpasswd -d "$USER" input` if you'd rather not. The `punktfunk` group is worth actually
|
||||
dropping once the host is gone, because it can present emulated USB hardware and nothing else uses
|
||||
it: `sudo gpasswd -d "$USER" punktfunk`.
|
||||
|
||||
See [Uninstalling](/docs/uninstall) for the other install methods and what each one leaves behind.
|
||||
|
||||
@@ -258,9 +272,14 @@ See [Uninstalling](/docs/uninstall) for the other install methods and what each
|
||||
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
||||
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
||||
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
|
||||
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` group and the
|
||||
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` **and
|
||||
`punktfunk`** groups and the
|
||||
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
|
||||
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
||||
degrades to a generic Xbox 360 controller (still fully playable). The second group is separate on
|
||||
purpose: it can present arbitrary emulated USB hardware, which is why it is not folded into the
|
||||
`input` group every gamepad guide tells you to join. Check both with `id -nG`, and check the nodes
|
||||
themselves with `ls -l /sys/devices/platform/vhci_hcd.0/attach` — group `punktfunk`, mode `0660`.
|
||||
If you're streaming *to* another
|
||||
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||
[Stream to a Steam Deck](/docs/steam-deck).
|
||||
- **It survives OS updates — automatically.** SteamOS A/B updates rebuild `/etc` and can move
|
||||
|
||||
@@ -250,6 +250,31 @@ switch mouse mode, disconnect, fullscreen — are in
|
||||
button (see [Updating](/docs/updating)). Swapping `punktfunk-host.exe` by hand does not fix it,
|
||||
because the stale controller device keeps the driver it was already bound to.
|
||||
|
||||
## The pad works, but arrives as an Xbox 360 controller instead of a Steam Deck
|
||||
|
||||
Only the **virtual Steam Deck controller** (paddles, trackpads, gyro) is missing here — ordinary
|
||||
gamepad input is fine. That pad reaches games as a real USB device over usbip, and the sysfs files
|
||||
it attaches through are owned by a group called `punktfunk`, separate from `input`. Four things
|
||||
have to line up on the Linux host, and none of them announces itself when it doesn't:
|
||||
|
||||
```sh
|
||||
getent group punktfunk # the group exists at all
|
||||
id -nG | tr ' ' '\n' | grep -x punktfunk # ...and you are in it
|
||||
ls -l /sys/devices/platform/vhci_hcd.0/attach # owned by punktfunk, mode 0660
|
||||
lsmod | grep vhci_hcd # the transport module is loaded
|
||||
```
|
||||
|
||||
If the group is missing entirely, the udev rule tried to `chgrp` to a group nobody created, so the
|
||||
nodes stayed root-only. That was the case on installs that reached 0.25.0 by **upgrade** on Arch,
|
||||
on NixOS, on the Bazzite sysext, and on Steam Deck source installs. Re-running your package
|
||||
manager's upgrade (or `update.sh` on a Deck) creates it now; otherwise `sudo groupadd --system
|
||||
punktfunk` by hand. Then `sudo usermod -aG punktfunk "$USER"` and **log out and back in** — group
|
||||
changes only reach the host's `systemd --user` service on a fresh login, and on a Deck a reboot is
|
||||
the reliable way to get one.
|
||||
|
||||
Joining the group is optional, and there is a real reason it is not automatic: writing that
|
||||
`attach` file materialises an arbitrary emulated USB device. Skip it on a machine you share.
|
||||
|
||||
## Copy and paste between host and client does nothing
|
||||
|
||||
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
|
||||
|
||||
@@ -111,6 +111,18 @@ re-login so the new group membership takes effect:
|
||||
sudo usermod -aG input "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||
`punktfunk`. That pad reaches games as a real USB device over usbip — which is what makes Steam
|
||||
Input adopt it — and the group gating those nodes is deliberately separate from `input`, because
|
||||
writing the usbip `attach` file can materialise arbitrary emulated USB hardware:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
Join it only on a machine you trust. Skip it and everything else still works; the pad just arrives
|
||||
as an ordinary Xbox 360 controller.
|
||||
|
||||
## 4. Check it installed
|
||||
|
||||
Before moving on, confirm the binary is there and nothing else is competing for the same job:
|
||||
|
||||
@@ -58,16 +58,20 @@ sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.as
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
**Left behind:** `~/.config/punktfunk`, and the empty `punktfunk-update` system group the package
|
||||
created for [one-click updates](/docs/updating). Clear them with:
|
||||
**Left behind:** `~/.config/punktfunk`, and the two system groups the package created — the empty
|
||||
`punktfunk-update` for [one-click updates](/docs/updating), and `punktfunk` for the virtual Steam
|
||||
Deck pad's usbip nodes. Clear them with:
|
||||
|
||||
```sh
|
||||
rm -rf ~/.config/punktfunk
|
||||
sudo groupdel punktfunk-update
|
||||
sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk
|
||||
```
|
||||
|
||||
Your `input` group membership is harmless to keep (it is a stock Ubuntu group). Drop it with
|
||||
`sudo gpasswd -d "$USER" input` if you'd rather not have it. If you opened the firewall, close it
|
||||
`sudo gpasswd -d "$USER" input` if you'd rather not have it. The `punktfunk` group above is worth
|
||||
dropping rather than keeping: it can present arbitrary emulated USB hardware, and with the host
|
||||
gone nothing uses it. If you opened the firewall, close it
|
||||
again: `sudo ufw delete allow punktfunk-native` (and `punktfunk-gamestream` / `punktfunk-web` if you
|
||||
allowed those too).
|
||||
|
||||
@@ -80,9 +84,12 @@ sudo dnf remove punktfunk punktfunk-web punktfunk-client punktfunk-scripting
|
||||
sudo rm -f /etc/yum.repos.d/punktfunk.repo
|
||||
```
|
||||
|
||||
**Left behind:** `~/.config/punktfunk`, the `punktfunk-update` group, and the signing key dnf
|
||||
**Left behind:** `~/.config/punktfunk`, the `punktfunk-update` and `punktfunk` groups, and the
|
||||
signing key dnf
|
||||
imported into the rpm keyring when it first installed a Punktfunk package. Clear the first two with
|
||||
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update`. The key is harmless to leave — on
|
||||
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update`; drop `punktfunk` too
|
||||
(`sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk`) — it can present arbitrary
|
||||
emulated USB hardware and nothing uses it once the host is gone. The key is harmless to leave — on
|
||||
its own it only marks packages from our registry as trusted, and nothing fetches them once the repo
|
||||
file is gone.
|
||||
|
||||
@@ -127,6 +134,7 @@ Three things it created outside `/usr` stay behind:
|
||||
```sh
|
||||
sudo rm -f /etc/modules-load.d/punktfunk.conf /etc/udev/rules.d/60-punktfunk.rules
|
||||
sudo groupdel punktfunk-update
|
||||
sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk
|
||||
```
|
||||
|
||||
And your config, if you want it gone: `rm -rf ~/.config/punktfunk`. See
|
||||
@@ -150,8 +158,10 @@ repo's signing key from pacman's keyring:
|
||||
sudo pacman-key --delete E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
```
|
||||
|
||||
**Left behind:** `~/.config/punktfunk` and the `punktfunk-update` group —
|
||||
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update` clear them. On CachyOS, close the
|
||||
**Left behind:** `~/.config/punktfunk` and the `punktfunk-update` and `punktfunk` groups —
|
||||
`rm -rf ~/.config/punktfunk`, `sudo groupdel punktfunk-update`, and
|
||||
`sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk` clear them. Drop that last one
|
||||
rather than keeping it: it can present arbitrary emulated USB hardware. On CachyOS, close the
|
||||
ufw rules you opened: `sudo ufw delete allow punktfunk-native`.
|
||||
|
||||
### SteamOS / Steam Deck host (on-device build)
|
||||
@@ -172,8 +182,10 @@ atomic-update keep list is what carries those files through every SteamOS update
|
||||
stay on the device indefinitely.
|
||||
|
||||
**Left behind:** `~/.config/punktfunk` (`rm -rf ~/.config/punktfunk` for a clean slate), your
|
||||
`input` group membership, and — if the installer seeded it because you had none — the KDE
|
||||
RemoteDesktop portal grant at `~/.local/share/flatpak/db/kde-authorized`.
|
||||
`input` and `punktfunk` group memberships, and — if the installer seeded it because you had none —
|
||||
the KDE RemoteDesktop portal grant at `~/.local/share/flatpak/db/kde-authorized`. Drop the second
|
||||
group once the host is gone — it can present arbitrary emulated USB hardware and nothing else on a
|
||||
Deck uses it: `sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk`.
|
||||
|
||||
### NixOS
|
||||
|
||||
@@ -184,9 +196,9 @@ There is nothing to uninstall imperatively — remove what you declared:
|
||||
input.
|
||||
3. Rebuild: `sudo nixos-rebuild switch`.
|
||||
|
||||
The unit, udev rules, sysctl tuning, firewall ports and `input` group membership all disappear with
|
||||
the generation. The store paths stay until you garbage-collect, and `~/.config/punktfunk` — which
|
||||
the module never managed — stays regardless.
|
||||
The unit, udev rules, sysctl tuning, firewall ports and the `input` / `punktfunk` group memberships
|
||||
all disappear with the generation. The store paths stay until you garbage-collect, and
|
||||
`~/.config/punktfunk` — which the module never managed — stays regardless.
|
||||
|
||||
## Windows host
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ This is the largest release so far — close to four hundred changes. The short
|
||||
|
||||
Most people need to do nothing. Check this list if any of it applies to you.
|
||||
|
||||
- **Linux, if you use the virtual Steam Deck controller: join a new group.** That permission used to ride on `input`, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad stops attaching. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust.
|
||||
- **Linux hosts, if you use the virtual Steam Deck controller: join a new group.** That permission used to ride on `input`, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad stops attaching. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust. This is a host-side step: a Linux machine you only *stream from* needs nothing. **If that command reports `group 'punktfunk' does not exist`**, you are on one of the install paths that shipped 0.25.0 without creating it — an Arch box upgraded rather than freshly installed, NixOS, the Bazzite sysext, or a Steam Deck source install. `sudo groupadd --system punktfunk` first, then the `usermod`; a later update creates it for you.
|
||||
- **Add-on interfaces moved to their own port (47993).** An existing firewall rule will not pick it up when you upgrade, and the symptom is a blank panel where the add-on's interface should be. On Linux the package prints the exact command — `sudo ufw app update punktfunk-web && sudo ufw reload`, or a firewalld reload. With Docker, publish `47993` as well. If you reach your console over a self-signed certificate, your browser needs to trust the new port once; the console shows a card with a link that does it.
|
||||
- **Windows hosts now need Steam installed** for streamed audio — it never has to run. Without it the host streams video only, and picks the drivers up on its own if you install Steam later. Two new devices, "Punktfunk Speakers" and "Punktfunk Microphone", will appear in your sound settings; that is this feature working. If you already have VB-CABLE, leave it — it still works as a fallback and is not removed.
|
||||
- **Saving a game with a custom launch command asks for your console password again**, and add-ons may no longer set launch commands at all. A third-party add-on that did will need updating by its author.
|
||||
- **A fresh install now runs the add-on runner by default.** Upgrades are untouched — if you switched it off, it stays off.
|
||||
- **If you set up a Steam Deck with the install script, consider rotating your console password.** It was written to a world-readable file; that is fixed.
|
||||
- **If you set up a Steam Deck with the install script, rotate your console password.** It was written to a world-readable file, so any local account could read it — and fixing the permissions does not un-share a password that was already readable, which is why this one is worth actually doing rather than considering. New installs are written correctly. On an existing one, re-run `install.sh` or `update.sh` to tighten the file, then change `PUNKTFUNK_UI_PASSWORD` in `~/.config/punktfunk/web.env` and `systemctl --user restart punktfunk-web`.
|
||||
- **If you play with motion controls, your aim sensitivity will change.** The gyro pipeline was wrong at every stage and is now measured against a real controller, so the numbers moved: a controller presented to games as a DualShock 4 was reporting motion **forty times too fast**, and a PlayStation pad plugged into an Android phone was reporting about **30% short**. If you turned a game's sensitivity down or up to cope, set it back. The Android case is the one people plausibly tuned around — that aim now needs a *higher* in-game sensitivity than you are used to.
|
||||
|
||||
## New
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
Wire-compatible with 0.25.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
Most of this release came from people reporting what 0.25.0 did on their own machines. Bluetooth headphones get their sound back on iPhone and iPad, a Steam Deck stops losing HEVC halfway through the week and stops moving the game behind the Steam menu, games running inside a gamescope session are finally told the refresh rate they are actually being given, and a Windows host recovers its display after the machine sleeps. Linux hosts also get a substantial round of streaming performance work.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Bluetooth headphones had no game audio on iPhone and iPad.** On the default settings the phone played through its own speaker instead of your headset. Update the client.
|
||||
- **On a Steam Deck, opening the Steam menu also moved the game.** The same controller drove both, so browsing the menu was steering whatever was running on the host behind it — an invisible second player. Update the client.
|
||||
- **A Steam Deck could lose HEVC and drop to H.264.** The "Full chroma" switch was promising something no Deck can decode, and the cost was the entire codec rather than a little crispness. It looked random because it is a per-profile setting. Update the client.
|
||||
- **Games in a gamescope session were told their display was 60 Hz.** A game capped itself to 60 while your stream ran at 120, and the in-session display settings offered nothing else. Update the host.
|
||||
- **One capture hiccup could slow a Linux host down until you restarted it.** A single timeout switched that host to the slow capture path for every session afterwards — including sessions with an entirely different desktop, which had never failed at anything — and said nothing.
|
||||
- **Windows hosts stopped recovering after sleep**, and **"Native resolution" streamed a smaller, blurry picture** on desktops that scale fractionally. Both fixed; update the host and the client respectively.
|
||||
|
||||
## Before you update
|
||||
|
||||
Most people need to do nothing. Check this list if any of it applies to you.
|
||||
|
||||
- **Linux, if the virtual Steam Deck controller never attached on 0.25.0:** this is why. 0.25.0 moved that permission onto a new `punktfunk` group, but four of the six ways of installing never created the group — so the permission step failed quietly, and `sudo usermod -aG punktfunk "$USER"` answered "group does not exist". Arch upgrades, Nix, the Bazzite system extension and the Steam Deck script are all fixed. After updating, run `sudo usermod -aG punktfunk "$USER"` and log back in. Ordinary virtual gamepads were never affected.
|
||||
- **Steam Deck, if `update.sh --pull` was aborting:** it no longer does, and it clears the mess it made. The updater had been rewriting one of its own tracked files on every run, so the next update refused to start and deleting that file by hand was the only way through. Nothing needs deleting now.
|
||||
- **If you turned "Full chroma" on and HEVC came and went:** leave the setting wherever you like. It no longer costs you the codec, and on hardware that cannot decode it the option simply stops being offered.
|
||||
- **Steam Deck, if you have been told the client was up to date:** it may not have been. The plugin's client update check had never once succeeded since 0.24 and reported "up to date" whenever it failed. Update the plugin, then check again.
|
||||
|
||||
## New
|
||||
|
||||
- **The statistics overlay is reachable on an Apple TV.** There had been no way to it from inside a stream at all — every other client cycles it with a key combination or a three-finger tap, and a TV has neither. Press **Select + X** on a controller, or **hold Play/Pause** on the Siri Remote. A tap on Play/Pause still right-clicks as before.
|
||||
- **An OLED palette.** A thirteenth colour scheme for the controller interface whose dark half is genuinely black — pixels switched off rather than very dark grey — with a faint indigo ember in one corner so the background still goes somewhere. On the Steam Deck, TV and handheld interfaces.
|
||||
- **Choose when the controller interface appears.** The switch that turns it on had also been deciding that it shows up only while a controller is plugged in. Those are now separate: keep the old behaviour, or have it always on. On every settings surface.
|
||||
- **Hide a single game.** Visibility used to be all-or-nothing per source — a Proton tool the filter missed, a demo, or something you would rather not have on the TV meant hiding its whole launcher. Individual titles can now be hidden, and the choice survives a re-scan.
|
||||
- **A gamescope session can offer more refresh rates** in Steam's in-session display settings, and the Deck's own performance overlay can be composited into what you stream instead of staying on the local screen. Both are off unless you ask for them.
|
||||
- **The patched gamescope is installable on Fedora and on Debian and Ubuntu**, not only Arch — so a virtual display in game mode no longer depends on which distribution you picked.
|
||||
- **Add-ons can publish games the host has no way to name.** A library add-on can now list a title whose launch is entirely its own business — an emulator with the core and flags you configured, say — and the host asks the add-on what to run at the moment you press play. Nothing runnable is stored or sent to a client, and an emulator that has moved is picked up on the next launch instead of leaving a dead tile behind.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Audio catches jitter before you can hear it.** The rule that decides how much sound to keep buffered only ever learned from failures you could already hear: it waited for three audible dropouts before deepening the buffer, and it re-tested a shallower one every few quiet seconds, paying for a wrong guess with a click, forever. It now reads the near-misses nobody hears, backs off after a probe that fails, and refills in one go rather than limping. Simulating ten minutes of a Wi-Fi power-saving pattern went from roughly two thousand audible events to nine. Update the client.
|
||||
- **A gamescope session says so when its refresh rate has been lost.** If something in the session's own configuration drops the setting that carries it, the stream still runs and still looks right while the game underneath is capped to 60 — which is exactly the kind of fault that costs a week to find. It is now one line in the log.
|
||||
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 15–18 ms at 95% game load, with the stream's frame rate collapsing along with it. The encoder can now work on two frames at once, and the capture path asks your desktop for enough buffers to keep up instead of taking whatever it was handed without ever expressing a preference. The switch that asks your graphics card to put that work ahead of the game's had also never been applied on Linux, and now is — though it stays dormant on an ordinary install, because switching it on requires a system privilege that turns out to stop KDE recognising the host at all. There is more on that below.
|
||||
- **Jumbo frames can now be proven rather than hoped for.** The whole path was dead code: the discovery that was supposed to find a larger packet size could never settle above the ordinary limit, so the setting that grows mid-stream was unreachable on every path that has ever existed. A network that genuinely carries big packets is now detected, and a wavelet session starts at the large size instead of never getting there — around six times fewer packets per frame. Still opt-in, on both ends.
|
||||
- **The configuration documentation caught up with 0.25**, including the jumbo-frame option and several other settings that had shipped with nothing written about them.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Bluetooth headphones got no game audio on iPhone and iPad.** With the microphone on — which is the default — the app was forcing output to the phone's own speaker, and that override outranks a Bluetooth headset. Wired headphones beat it, which is why plugging in a cable made it look correct. Turning the microphone off was the accidental workaround people found. Audio now goes to whatever you have connected, and dropping a headset mid-stream no longer lands on the earpiece. Update the client.
|
||||
- **On a Steam Deck, the Steam menu and the Quick Access Menu also moved the game.** Both are driven by the same physical controller the client is forwarding, so opening either one played the game behind it at the same time — a second, invisible player picking things up and walking into walls while you browsed. Steam masks a normal game here and cannot mask this one, because the client deliberately forwards your real controller rather than Steam's stand-in, which has no gyro, trackpads or paddles. The stream now stops forwarding while an overlay has the controller, and hands it back without the button that dismissed the menu firing in the game. Update the client.
|
||||
- **Streaming a KDE desktop keeps working.** An interim build of this release gave the host an extra system privilege, so that the wavelet encoder could ask your graphics card for priority. On KDE the side effect was total: KDE decides whether it trusts a program by looking up which file it is running from, the system refuses that lookup for any program holding a privilege, and so KDE stopped recognising the host at all — desktop streaming failed outright, complaining about a missing screen-capture interface, and it survived a clean reinstall of both host and client. Reported from CachyOS on both NVIDIA and AMD. No Linux package grants that privilege any more, on any of the five ways we ship, and upgrading strips it from a machine that already has it. If a host somehow holds one anyway, the error now names it and gives you the command that undoes it, instead of blaming a missing desktop file.
|
||||
- **One capture timeout could slow a Linux host down for good.** Two very different problems shared a single switch: a graphics driver that genuinely cannot handle what your desktop produces, and a desktop that simply happened to be restarting. The second was being treated as permanently as the first, so a single moment of bad timing put that host on the slow capture path for every session until the process was restarted — including sessions against a completely different desktop that had never failed at anything, and with nothing at all in the log. The two now have the lifetimes they should, and a capture that works credits the budget back.
|
||||
- **A wavelet-codec session could quietly fall back to slow capture and log nothing whatsoever.** The warning was asking a host-wide question about a per-session decision, so a degraded host and a healthy one produced identical logs while one of them touched every pixel on the processor.
|
||||
- **"Full chroma" could cost a Steam Deck its codec.** The client advertised the feature on the strength of the setting alone, with nothing checking whether the device could decode it — and no AMD hardware can. The host grants it on HEVC only, so a Deck with the switch on lost HEVC entirely and reconnected on H.264. It looked intermittent because it is a per-profile setting: a "Work" profile lost HEVC where "Game" kept it, on the same machine and the same host. The client now asks the graphics driver the same question the decoder will, so the advertisement and what actually works cannot disagree.
|
||||
- **The decoder triage tool reported the opposite of the truth on a Steam Deck.** `--probe-decode` ran before the Deck's own video-decode option was applied, so it said no hardware decoding was available on a machine that had been decoding on it all along — and anyone triaging a Deck with it was led away from the answer.
|
||||
- **Every gamescope session ended in a crash.** The graphics device was being torn down after the driver behind it had already been unloaded, so the process fell over on the way out — after the compositor had finished, which is why the stream looked perfectly fine and this surfaced only as crash reports piling up. Five in one ordinary ten-minute session.
|
||||
- **A Windows host no longer refuses its own recovery after sleep.** When a machine woke with its virtual display detached, every repair attempt came back refused — because the host was still holding the device open, and that is precisely what blocks the reset the repair depends on. Stopping the whole service by hand was the only thing that worked. The host now lets go before it asks.
|
||||
- **"Native resolution" streamed the wrong resolution on fractional scaling.** A 2560×1600 laptop panel at 150% negotiated 1706×1066 and streamed a visibly soft picture: the desktop reports its size in scaled units, and that was being read as pixels. The window itself was not high-density either, so even a correct stream was shrunk and then stretched back. Both are fixed, and "Match window" was quietly losing the same detail. Wayland desktops that scale fractionally — KDE among them.
|
||||
- **Pinning a compositor silently cancelled dedicated game sessions.** A host set to launch games into their own session went on saying it did while every launch landed on the desktop instead, with nothing in the log either way — on the machine that surfaced this, for thirty days. The pin still wins, but it now says so and names itself. The same pin also made a configured session-recovery command unreachable, and a mid-startup mode change could take the whole GNOME desktop down with it, killing the game it had just launched.
|
||||
- **The Steam Deck plugin's "update the client" had never once detected an update.** It asked about the app without saying which release channel, the answer was refused as ambiguous, and a failed check was displayed as "up to date" — so it went a week unnoticed while offering to update only itself. A check that cannot run now says so.
|
||||
- **The Steam Deck updater kept sabotaging its own next update.** It rewrote a tracked generated file on every run, so the following update refused to start and hand-deleting that file was the only way past it. It no longer creates the mess, and it clears the one already there.
|
||||
- **Add-on scanners appeared in the console sidebar they had opted out of**, their settings could not be reached from the Library screen, and syncing artwork from a local folder failed — one disagreement about how a folder path is written, and a change that had never been published. Library source settings also stopped opening at all after add-ons moved to their own address.
|
||||
- **A fix to the add-on toolkit could never reach an add-on already installed**, because the copy each one runs was pinned at install time and nothing updated it.
|
||||
- **A routine system update can no longer leave an Arch or CachyOS host unable to start**, and the rebuild that ships the corrected package no longer fails on a step that cannot exist during a rebuild. This is the same fault 0.25.0-2 was published for; it is now fixed in the pipeline rather than by hand.
|
||||
- **A stale virtual monitor left behind by a crash is cleaned up properly**, and when the cleanup is refused it says why instead of failing silently or aiming at a device that is no longer there.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and add-on detail — the version table, the new environment variables and what did *not* move — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.26.0/CHANGELOG.md).
|
||||
|
||||
The short version: **nothing breaks.** The wire protocol stays at 2 and the C ABI stays at 17, so this release adds no new call, no new message and no new capability bit.
|
||||
|
||||
If you write a library add-on, there is one addition worth reading about: the new launch kind that lets you publish a title the host cannot name, and the add-on toolkit release that carries it.
|
||||
@@ -0,0 +1,3 @@
|
||||
• New: an OLED colour scheme whose dark half is truly black, not dark grey.
|
||||
• The controller interface can now stay on screen when no controller is attached, instead of only appearing while one is plugged in.
|
||||
• Sound catches jitter before you can hear it. The buffer learns from the near-misses nobody notices rather than waiting for three audible dropouts, so a busy Wi-Fi network clicks far less.
|
||||
@@ -173,8 +173,10 @@ systemctl --user enable --now punktfunk-host # the user unit is now under /u
|
||||
```
|
||||
The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext
|
||||
exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext
|
||||
path: file capabilities don't survive it, so gamescope runs without `CAP_SYS_NICE` — everything
|
||||
works, frame pacing is marginally worse than the pacman install, whose `.install` sets the cap.)
|
||||
path: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse —
|
||||
everything works. Note the host binary carries no capability on *either* path, deliberately: one
|
||||
would make the host unidentifiable to KWin and break desktop streaming, see
|
||||
[Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).)
|
||||
|
||||
## Steam Deck — the client (what the Decky plugin launches)
|
||||
|
||||
|
||||
@@ -14,9 +14,17 @@
|
||||
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
|
||||
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
|
||||
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
|
||||
# the host then stays SDR on that backend, by design. (No CAP_SYS_NICE inside the image: file
|
||||
# capabilities don't survive this squashfs path — gamescope runs without it, pacing slightly
|
||||
# worse, same as the Bazzite sysext.)
|
||||
# the host then stays SDR on that backend, by design.
|
||||
#
|
||||
# No CAP_SYS_NICE inside the image, for either binary. ⚠ NOT because capabilities are lost on the
|
||||
# way in — that was this comment's earlier claim and it is false: mksquashfs records
|
||||
# security.capability, and the published Bazzite 0.26.0-1 image really did carry `cap_sys_nice=ep`
|
||||
# on usr/bin/punktfunk-host. It is left out on purpose. A capability on the HOST binary makes it
|
||||
# unidentifiable to KWin (which resolves a client's /proc/<pid>/exe to match it against a .desktop,
|
||||
# and cannot read it for a capability-carrying process) and kills every Desktop-mode session — see
|
||||
# packaging/bazzite/build-sysext.sh, which now hard-fails if one is staged. `punktfunk-gamescope`
|
||||
# is a compositor, not a KWin client, so it is unaffected by that rule and simply runs without the
|
||||
# capability here, pacing slightly worse.
|
||||
set -euo pipefail
|
||||
|
||||
GAMESCOPE=""
|
||||
|
||||
@@ -12,9 +12,48 @@ _ensure_punktfunk_group() {
|
||||
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
|
||||
}
|
||||
|
||||
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted.
|
||||
#
|
||||
# 0.26.0-1 ran `setcap cap_sys_nice=ep` here, to let the encoder open an elevated global-priority
|
||||
# Vulkan queue (PyroWave shares the GPU's shader cores with the game; measured 2026-08-08 on an
|
||||
# RTX 5070 Ti, the encode dispatch goes ~2 ms -> 15-18 ms at 95 % game load without it). That grant
|
||||
# BROKE DESKTOP STREAMING ON EVERY KDE BOX, and it cannot be made to work — the two are mutually
|
||||
# exclusive at the kernel level:
|
||||
#
|
||||
# KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1, which mints our
|
||||
# virtual output, and org_kde_kwin_fake_input, which injects input) only to a client it can
|
||||
# IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
|
||||
# .desktop's Exec= (ours is io.unom.Punktfunk.Host.desktop). The kernel refuses that readlink to
|
||||
# any reader whose effective set is not a superset of the target's PERMITTED set
|
||||
# (cap_ptrace_access_check), and KWin holds no capabilities. So the moment this binary carries a
|
||||
# capability it becomes unidentifiable: KWin's executablePath() is empty, nothing matches, the
|
||||
# globals are never advertised, and every session dies with
|
||||
# "KWin does not expose zkde_screencast_unstable_v1 to this client" after 8 retries — while
|
||||
# looking exactly like a missing or wrong .desktop file.
|
||||
#
|
||||
# Verified on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
|
||||
# no capability .............................. readlink /proc/<pid>/exe OK
|
||||
# capability ................................. EPERM
|
||||
# capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
|
||||
# capability dropped + PR_SET_DUMPABLE(1) .... OK <- only a capability-free process works
|
||||
#
|
||||
# The third row also rules out the obvious "move it to the systemd unit": AmbientCapabilities= puts
|
||||
# CAP_SYS_NICE in exactly the same permitted set and fails identically. Nothing short of not having
|
||||
# the capability restores identification, so the host does not get one. The encoder already walks
|
||||
# REALTIME -> HIGH -> default when the class is refused (pf-zerocopy vulkan.rs), so this costs
|
||||
# pacing under a GPU-bound game and nothing else — 0.25.0's behaviour exactly.
|
||||
#
|
||||
# The removal below heals boxes that ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode
|
||||
# and file capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade
|
||||
# paths — cheap, and the failure it prevents is an 8-retry session death with a misleading message.
|
||||
_revoke_sched_capability() {
|
||||
setcap -r usr/bin/punktfunk-host 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_install() {
|
||||
_ensure_update_group
|
||||
_ensure_punktfunk_group
|
||||
_revoke_sched_capability
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger --subsystem-match=misc 2>/dev/null || true
|
||||
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
|
||||
@@ -67,6 +106,14 @@ MSG
|
||||
|
||||
post_upgrade() {
|
||||
_ensure_update_group
|
||||
# Also on UPGRADE, not just post_install: 'punktfunk' was introduced in 0.25.0, so every box that
|
||||
# reached it by `pacman -Syu` from 0.24.x ran only this function and never got the group at all —
|
||||
# leaving 60-punktfunk.rules to chgrp to a nonexistent group, the vhci attach/detach nodes
|
||||
# root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so
|
||||
# this is a no-op on boxes that installed fresh.
|
||||
_ensure_punktfunk_group
|
||||
# Strip the cap_sys_nice 0.26.0-1 granted: it makes the host unidentifiable to KWin (see above).
|
||||
_revoke_sched_capability
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||
_warn_stale_firewall_ports
|
||||
|
||||
@@ -421,11 +421,23 @@ bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
|
||||
# then log out + back into the KDE Desktop session once (or reboot) so KWin restarts with the flag
|
||||
```
|
||||
|
||||
That writes `~/.config/environment.d/10-punktfunk-kwin.conf`
|
||||
(`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`) and seeds the `kde-authorized` RemoteDesktop grant into
|
||||
`~/.local/share/flatpak/db/`. Gaming Mode is unaffected. To connect from Desktop Mode, switch to it
|
||||
(Steam → Power → Switch to Desktop), then connect the client; switching **mid-stream** requires a
|
||||
reconnect (the host resolves the backend per connect).
|
||||
That seeds the `kde-authorized` RemoteDesktop grant into `~/.local/share/flatpak/db/` — the input
|
||||
half. The **video** half needs no session-wide override: the image ships
|
||||
`io.unom.Punktfunk.Host.desktop`, whose `X-KDE-Wayland-Interfaces` grants the host KWin's
|
||||
`zkde_screencast` protocol on a normal Plasma login (least-privilege — only this binary, only that
|
||||
interface). Older versions of the script wrote a session-wide
|
||||
`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` into `~/.config/environment.d/10-punktfunk-kwin.conf`; it now
|
||||
*removes* that file as an over-broad leftover. Gaming Mode is unaffected. To connect from Desktop
|
||||
Mode, switch to it (Steam → Power → Switch to Desktop), then connect the client; switching
|
||||
**mid-stream** requires a reconnect (the host resolves the backend per connect).
|
||||
|
||||
> **On 0.26.0-1 specifically, Desktop mode is broken and no amount of this setup fixes it.** That
|
||||
> image shipped `cap_sys_nice=ep` on `/usr/bin/punktfunk-host`, and a capability-carrying process is
|
||||
> one KWin cannot identify (it resolves `/proc/<pid>/exe` to match the `.desktop`, and the kernel
|
||||
> refuses that read), so the session dies with `KWin does not expose zkde_screencast_unstable_v1 to
|
||||
> this client`. A merged sysext's `/usr` is read-only, so it cannot be repaired in place — take the
|
||||
> next image (`sudo punktfunk-sysext update`). `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` works around it
|
||||
> meanwhile by disabling the check that needs the identification.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -130,6 +130,38 @@ SYSEXT_VERSION_ID=$PF_VR
|
||||
EXTENSION_RELOAD_MANAGER=1
|
||||
EOF
|
||||
|
||||
# NO CAP_SYS_NICE in the image — and an assertion that none crept back in.
|
||||
#
|
||||
# 0.26.0-1 setcap'd the staged binary here for the GPU-priority lever. mksquashfs records
|
||||
# security.capability, so the capability really did ship: verified by mounting the published
|
||||
# punktfunk-0.26.0-1-x86-64.raw, where `getcap usr/bin/punktfunk-host` reports `cap_sys_nice=ep`.
|
||||
# That broke desktop streaming on every Bazzite KDE box, field-reported as
|
||||
# "KWin does not expose zkde_screencast_unstable_v1 to this client".
|
||||
#
|
||||
# KWin advertises its restricted protocols (zkde_screencast_unstable_v1 for the virtual output,
|
||||
# org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that client's
|
||||
# /proc/<pid>/exe and matching it against an installed .desktop's Exec= — the image ships
|
||||
# usr/share/applications/io.unom.Punktfunk.Host.desktop for exactly that. The kernel refuses that
|
||||
# readlink to any reader whose effective set is not a superset of the target's PERMITTED set
|
||||
# (cap_ptrace_access_check), and KWin holds no capabilities. So a capability in this image makes the
|
||||
# host unidentifiable and every Desktop-mode session dies. Full matrix, including why neither
|
||||
# prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in
|
||||
# packaging/arch/punktfunk-host.install.
|
||||
#
|
||||
# A merged sysext's /usr is a read-only squashfs, so this cannot be repaired on the box — the image
|
||||
# is the only place it can be got right. Assert it rather than trust it: the RPM payload arrives via
|
||||
# `rpm2cpio | cpio`, which carries no capabilities today, but the spec is one `%caps()` away from
|
||||
# changing that and this build would silently bake it in.
|
||||
if [ -f "$STAGE/usr/bin/punktfunk-host" ] && command -v getcap >/dev/null 2>&1; then
|
||||
staged_caps="$(getcap "$STAGE/usr/bin/punktfunk-host" 2>/dev/null || true)"
|
||||
if [ -n "$staged_caps" ]; then
|
||||
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
|
||||
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
|
||||
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
|
||||
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
|
||||
# handful of matches all resolve to real contexts for our payload).
|
||||
|
||||
@@ -167,6 +167,13 @@ post_merge() {
|
||||
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
|
||||
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
|
||||
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
|
||||
# 'punktfunk' owns the vhci attach/detach nodes the rule we just mirrored into /etc chgrp's to.
|
||||
# A group cannot ride an image either (/etc/group is host state), and the deb/rpm scriptlets that
|
||||
# would normally create it never run on an image-based install — so without this the chgrp fails,
|
||||
# attach/detach stay root-only and the virtual Steam Deck pad never attaches. Deliberately NOT
|
||||
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
|
||||
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
|
||||
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
|
||||
modprobe vhci-hcd 2>/dev/null || :
|
||||
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
|
||||
# the input-group ownership even when the module's original add event predated the reloaded rule.
|
||||
|
||||
@@ -294,6 +294,23 @@ if [ "$1" = "configure" ]; then
|
||||
# primitive that must not ride on the group users are told to join for gamepads
|
||||
# (security-review 2026-08-05 M-4).
|
||||
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
|
||||
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted here.
|
||||
#
|
||||
# 0.26.0-1 ran `setcap cap_sys_nice=ep` at this point for the GPU-priority lever, and that broke
|
||||
# desktop streaming on every KDE box. KWin advertises its restricted protocols
|
||||
# (zkde_screencast_unstable_v1 for the virtual output, org_kde_kwin_fake_input for input) only
|
||||
# to a client it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it
|
||||
# against an installed .desktop's Exec=. The kernel refuses that readlink to any reader whose
|
||||
# effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check), and
|
||||
# KWin holds no capabilities — so a capability here makes the host unidentifiable and the
|
||||
# session dies with "KWin does not expose zkde_screencast_unstable_v1 to this client". Full
|
||||
# matrix (and why PR_SET_DUMPABLE and AmbientCapabilities= both fail to rescue it) in
|
||||
# packaging/arch/punktfunk-host.install.
|
||||
#
|
||||
# Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a class is refused.
|
||||
# postinst runs on upgrade too, so this heals boxes that installed 0.26.0-1. `setcap -r` exits
|
||||
# non-zero on a file that has no capability, hence the redirect and `|| true`.
|
||||
setcap -r /usr/bin/punktfunk-host 2>/dev/null || true
|
||||
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger --subsystem-match=misc 2>/dev/null || true
|
||||
|
||||
Executable
+113
@@ -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"
|
||||
@@ -65,6 +65,14 @@ finish-args:
|
||||
- --socket=wayland # GTK4 native Wayland window (the client is Wayland-first)
|
||||
- --socket=fallback-x11 # Xwayland fallback when no Wayland socket is exposed
|
||||
- --share=ipc # required alongside X11 for shared-memory surfaces
|
||||
# Gaming Mode's overlay signal lives on a DIFFERENT X server than ours. gamescope runs
|
||||
# `--xwayland-count 2`: Steam and the GAMESCOPE_FOCUSED_APP/_GFX atoms are on the first,
|
||||
# the app is handed the second, so `$DISPLAY` alone can never see them — and --socket=x11
|
||||
# would not help, since flatpak binds only the ONE socket named by DISPLAY. Read-only
|
||||
# access to the socket directory is what lets `overlay_focus` reach the root ctx and stop
|
||||
# forwarding the pad while the Steam menu / QAM is up. gamescope's Xwayland takes
|
||||
# unauthenticated local connections, so no cookie has to cross with it.
|
||||
- --filesystem=/tmp/.X11-unix:ro
|
||||
# --- GPU + all input devices ---
|
||||
# --device=all (not just --device=dri): covers the GPU render node (VAAPI HEVC decode + GL),
|
||||
# evdev joysticks, AND the hidraw CHAR devices SDL3's HIDAPI needs for DualSense touchpad/
|
||||
|
||||
@@ -19,8 +19,10 @@ 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"
|
||||
pkgrel=1
|
||||
pkgver="${_gsver}.pfhdr4"
|
||||
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
|
||||
# `.pfhdrN` level deliberately stays put — see README.md.
|
||||
pkgrel=2
|
||||
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://git.unom.io/unom/punktfunk"
|
||||
|
||||
@@ -13,7 +13,22 @@ 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 |
|
||||
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
|
||||
|
||||
### 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,9 +55,17 @@ 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.
|
||||
|
||||
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
|
||||
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
|
||||
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
|
||||
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
|
||||
and strand hosts that gate on it.
|
||||
|
||||
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
|
||||
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
|
||||
session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR
|
||||
@@ -142,7 +165,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
|
||||
|
||||
Executable
+78
@@ -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)"
|
||||
+247
@@ -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 ||
|
||||
+142
@@ -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;
|
||||
+14
-10
@@ -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)
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||
Date: Sat, 8 Aug 2026 19:17:25 +0200
|
||||
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Every gamescope session punktfunk spawns ended in SIGSEGV. It happened after
|
||||
the compositor had already done its work — "Primary child shut down!", then a
|
||||
coredump — so the stream itself looked fine and the crash only showed up as a
|
||||
steady drip of coredumps and a non-zero exit from the spawn.
|
||||
|
||||
The cause is static destruction order, not anything gamescope does wrong at
|
||||
runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were plain
|
||||
globals, so glibc ran their destructors from `__run_exit_handlers` once main()
|
||||
returned. Those destructors call back into the driver:
|
||||
|
||||
~CVulkanCmdBuffer -> m_device->vk.FreeCommandBuffers(...)
|
||||
~CVulkanTexture -> vk.Destroy*(...)
|
||||
|
||||
but the Vulkan ICD has already been torn down and unloaded by that point, so
|
||||
each call jumps through a function pointer into an unmapped page. The faulting
|
||||
address equals the instruction pointer, which is the signature of exactly that:
|
||||
|
||||
#0 0x00007fe8fd1d1070 in ?? ()
|
||||
#1 CVulkanCmdBuffer::~CVulkanCmdBuffer at rendervulkan.cpp:1543
|
||||
#9 std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector (g_device+1792)
|
||||
#10 CVulkanDevice::~CVulkanDevice at rendervulkan.hpp:768
|
||||
#11 __run_exit_handlers / exit()
|
||||
|
||||
On NVIDIA it is 100% reproducible:
|
||||
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true`
|
||||
exits 139 every time, and cleanly with this patch (5/5, plus 2/2 at the real
|
||||
session's 2752x2064@120 --steam).
|
||||
|
||||
Nothing needs freeing at that point. The process is exiting; the kernel
|
||||
reclaims the device, its command buffers and every GPU allocation. So give both
|
||||
objects storage that is constructed exactly as before but never destroyed — a
|
||||
union member is destroyed only if the union's destructor says so, and ours
|
||||
deliberately does not. `g_device` and `g_output` keep their names and types
|
||||
(now references bound at constant-initialisation time), so no use site changes.
|
||||
|
||||
Both are needed: pinning only the device relocated the fault into
|
||||
~VulkanOutput_t, which is why this is a shared helper and not a one-off.
|
||||
---
|
||||
src/rendervulkan.cpp | 35 +++++++++++++++++++++++++++++++++--
|
||||
src/rendervulkan.hpp | 4 ++--
|
||||
2 files changed, 35 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
|
||||
index 5c2dd11..8cd5ca2 100644
|
||||
--- a/src/rendervulkan.cpp
|
||||
+++ b/src/rendervulkan.cpp
|
||||
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
|
||||
return s_result;
|
||||
}
|
||||
|
||||
-VulkanOutput_t g_output;
|
||||
+// punktfunk: globals that own GPU objects must OUTLIVE static destruction.
|
||||
+//
|
||||
+// gamescope kept its Vulkan device and output as plain globals, so glibc ran their destructors
|
||||
+// from `__run_exit_handlers` after main() returned. Those destructors call back into the driver
|
||||
+// (~CVulkanCmdBuffer -> vk.FreeCommandBuffers, ~CVulkanTexture -> vk.Destroy*), but by then the
|
||||
+// Vulkan ICD has already been torn down and unloaded, so the call jumps through a function
|
||||
+// pointer into an unmapped page: SIGSEGV at exactly the address it tried to execute. On NVIDIA it
|
||||
+// is 100% reproducible -- `gamescope --backend headless ... -- true` dies with exit 139 EVERY
|
||||
+// time -- so every punktfunk gamescope session ended in a coredump.
|
||||
+//
|
||||
+// Nothing needs freeing at that point: the process is exiting and the kernel reclaims the device,
|
||||
+// its command buffers and every GPU allocation. So give these objects storage that is constructed
|
||||
+// exactly as before but NEVER destroyed. A union member is only destroyed if the union says so,
|
||||
+// and ours deliberately does not.
|
||||
+//
|
||||
+// Fixing only one of them just moves the crash to the next global (verified: pinning the device
|
||||
+// relocated the fault into ~VulkanOutput_t), which is why this is a shared helper rather than a
|
||||
+// one-off.
|
||||
+namespace
|
||||
+{
|
||||
+ template <typename T>
|
||||
+ union CNoDestroy
|
||||
+ {
|
||||
+ T value;
|
||||
+ CNoDestroy() : value() {}
|
||||
+ ~CNoDestroy() {} // deliberately does NOT destroy `value`
|
||||
+ };
|
||||
+}
|
||||
+
|
||||
+namespace { CNoDestroy<VulkanOutput_t> g_outputHolder; }
|
||||
+VulkanOutput_t &g_output = g_outputHolder.value;
|
||||
|
||||
uint32_t g_uCompositeDebug = 0u;
|
||||
gamescope::ConVar<uint32_t> cv_composite_debug{ "composite_debug", 0, "Debug composition flags" };
|
||||
@@ -1943,7 +1973,8 @@ void CVulkanCmdBuffer::insertBarrier(bool flush)
|
||||
0, 0, nullptr, 0, nullptr, barriers.size(), barriers.data());
|
||||
}
|
||||
|
||||
-CVulkanDevice g_device;
|
||||
+namespace { CNoDestroy<CVulkanDevice> g_deviceHolder; }
|
||||
+CVulkanDevice &g_device = g_deviceHolder.value;
|
||||
|
||||
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
|
||||
{
|
||||
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
|
||||
index b6749d4..a9335c4 100644
|
||||
--- a/src/rendervulkan.hpp
|
||||
+++ b/src/rendervulkan.hpp
|
||||
@@ -564,7 +564,7 @@ enum ShaderType {
|
||||
SHADER_TYPE_COUNT
|
||||
};
|
||||
|
||||
-extern VulkanOutput_t g_output;
|
||||
+extern VulkanOutput_t &g_output;
|
||||
|
||||
struct SamplerState
|
||||
{
|
||||
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
|
||||
// Whether the driver implements VK_EXT_physical_device_drm
|
||||
bool vulkan_has_drm_props();
|
||||
|
||||
-extern CVulkanDevice g_device;
|
||||
+extern CVulkanDevice &g_device;
|
||||
--
|
||||
2.55.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user