Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4b514cc07c | ||
|
|
f242b2d2fc | ||
|
|
27ceab2f6c | ||
|
|
30bd10e301 | ||
|
|
1df39d9617 | ||
|
|
e4f8c64b9f | ||
|
|
690ff7016b | ||
|
|
6cffe29b13 | ||
|
|
44c87d7ac1 | ||
|
|
975fef2048 | ||
|
|
9089651406 | ||
|
|
2a2427afc8 | ||
|
|
d237646c66 | ||
|
|
69728b6f4e | ||
|
|
3bb87d260e | ||
|
|
be57587572 | ||
|
|
8f1c34c6bf | ||
|
|
1ef212a78d | ||
|
|
e044f68500 | ||
|
|
8c94e2517e |
+181
-8
@@ -48,7 +48,26 @@ on:
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
|
||||
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
|
||||
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
|
||||
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
|
||||
# whole transaction at once, so it did not merely block our package, it blocked those users'
|
||||
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
|
||||
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
|
||||
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
|
||||
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
|
||||
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
|
||||
required: false
|
||||
default: ''
|
||||
pkgrel:
|
||||
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
|
||||
required: false
|
||||
default: '2'
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
@@ -94,7 +113,52 @@ jobs:
|
||||
}
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
|
||||
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
|
||||
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
|
||||
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
|
||||
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
|
||||
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
|
||||
# on this one axis. So heal it in-job and shout, instead of building a dead package.
|
||||
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
|
||||
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
|
||||
run: |
|
||||
export LC_ALL=C # `Provides` is a localized field name
|
||||
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
|
||||
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
|
||||
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
|
||||
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
|
||||
mkdir -p /tmp/pf-archsync
|
||||
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
|
||||
exit 0
|
||||
fi
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
|
||||
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
|
||||
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
|
||||
if [ "$HAVE" = "$WANT" ]; then
|
||||
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
|
||||
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
|
||||
pacman -Syu --noconfirm || true
|
||||
HAVE="$(pacman -Qi ffmpeg | sonames)"
|
||||
if [ "$HAVE" != "$WANT" ]; then
|
||||
echo "::error::builder still links $HAVE while Arch ships $WANT."
|
||||
echo "::error::Building on would publish a package no Arch box can install."
|
||||
exit 1
|
||||
fi
|
||||
echo "healed: builder now links $HAVE"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
|
||||
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
|
||||
# = checkout's own default, i.e. the triggering ref, for every other trigger.
|
||||
ref: ${{ github.event.inputs.release_tag }}
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
@@ -127,12 +191,30 @@ jobs:
|
||||
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
|
||||
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
|
||||
# it to `1.` would sort canaries ABOVE the release and is not an option.)
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
|
||||
# top): same upstream version, higher pkgrel, straight into the stable repo.
|
||||
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
|
||||
# advertises by STRING order (the same trap the canary zero-padding below exists for),
|
||||
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
|
||||
V="${RELEASE_TAG#v}"
|
||||
R="${REBUILD_PKGREL:-2}"
|
||||
REPO=punktfunk
|
||||
case "$R" in
|
||||
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
|
||||
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
fi
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
@@ -235,6 +317,63 @@ jobs:
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
|
||||
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
|
||||
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
|
||||
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
|
||||
# one transaction, their whole system upgrade stopped there. So ask the only question that
|
||||
# matters before publishing: would a real, up-to-date Arch box install this?
|
||||
#
|
||||
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
|
||||
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
|
||||
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
|
||||
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
|
||||
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
|
||||
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
|
||||
- name: Assert every package installs on an up-to-date Arch box
|
||||
run: |
|
||||
export LC_ALL=C
|
||||
mkdir -p /tmp/pf-instcheck
|
||||
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
|
||||
echo "::error::could not sync the Arch db — cannot prove these packages install"
|
||||
exit 1
|
||||
fi
|
||||
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
|
||||
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
|
||||
}
|
||||
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
|
||||
rc=0
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
rc=1
|
||||
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
|
||||
# install is dropped from the upload with a warning, never a reason to withhold the
|
||||
# packages this workflow exists to publish. (It is also the one package that can be
|
||||
# restored from a cache older than the current Arch snapshot.)
|
||||
for pkg in dist-gamescope/*.pkg.tar.zst; do
|
||||
[ -e "$pkg" ] || continue
|
||||
if out="$(check "$pkg")"; then
|
||||
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
|
||||
else
|
||||
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
|
||||
echo "$out" | sed 's/^/ /'
|
||||
rm -f "$pkg"
|
||||
fi
|
||||
done
|
||||
if [ "$rc" != 0 ]; then
|
||||
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
|
||||
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
|
||||
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
|
||||
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
|
||||
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
|
||||
# per-partition-set), which is the standing packaging verdict behind the on-device
|
||||
@@ -262,14 +401,48 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
|
||||
# rebuild attaches to that SAME release object: the release page is a distribution surface
|
||||
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
|
||||
# someone the exact break the rebuild exists to fix.
|
||||
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||
RID=$(ensure_release "$TAG" "$TAG" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
|
||||
# upsert_asset only replaces by name — the superseded set would survive untouched.
|
||||
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
|
||||
#
|
||||
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
|
||||
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
|
||||
# so it can only ever offer the helpers that existed when that tag was cut. A helper
|
||||
# added for this feature is therefore guaranteed ABSENT in the one code path that
|
||||
# calls it: the first attempt failed with `prune_release_assets: command not found`
|
||||
# after publishing perfectly. Only the workflow file itself is taken from the ref you
|
||||
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
|
||||
# that tag — the PKGBUILD is the tag's too.
|
||||
if [ -n "${RELEASE_TAG:-}" ]; then
|
||||
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
|
||||
# An UNMATCHED glob would come through literally and match nothing in the keep set —
|
||||
# i.e. "delete every pacman asset on the release". Skip entirely instead.
|
||||
case "$KEEP" in *'*'*) KEEP="" ;; esac
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -n "$KEEP" ]; then
|
||||
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
|
||||
| while read -r id name; do
|
||||
[ -n "$id" ] || continue
|
||||
echo "dropping superseded release asset: $name"
|
||||
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
|
||||
-H "Authorization: token $GITEA_TOKEN" || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -320,6 +320,46 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -213,6 +213,40 @@ jobs:
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
|
||||
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
|
||||
# until it existed those users had no packaged route to the patched build at all, and a stock
|
||||
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
|
||||
#
|
||||
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
||||
fi
|
||||
|
||||
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for rpm in dist/punktfunk-gamescope-*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
echo "uploading $rpm"
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
|
||||
+438
@@ -12,6 +12,412 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.26.0
|
||||
|
||||
47 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 now grants; without it the lever does nothing.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0
|
||||
|
||||
407 commits since v0.24.0.
|
||||
@@ -79,6 +485,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.
|
||||
@@ -437,6 +857,24 @@ refuses the upgrade instead of bricking the install. All seven libs are listed e
|
||||
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
|
||||
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
|
||||
|
||||
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
|
||||
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
|
||||
the release tag was pushed four minutes later, while the CI builder image was still being
|
||||
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
|
||||
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
|
||||
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
|
||||
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
|
||||
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
|
||||
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
|
||||
time and could not disagree with itself this way.
|
||||
|
||||
Two guards now stand where only a convention did. arch.yml compares the builder's libav
|
||||
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
|
||||
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
|
||||
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
|
||||
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
|
||||
cheap path; the guards are the backstop.
|
||||
|
||||
### Linux playback filled the buffer ceiling
|
||||
|
||||
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
|
||||
|
||||
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"
|
||||
|
||||
+125
-4
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.24.0"
|
||||
"version": "0.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -997,7 +997,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1021,13 +1021,13 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unified library across all stores",
|
||||
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
"$ref": "#/components/schemas/OperatorGameEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1301,6 +1301,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/hidden/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Hide or un-hide one library title",
|
||||
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
|
||||
"operationId": "setLibraryEntryHidden",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The library entry id (e.g. `steam:70`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored; the entry's visibility after the call",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HiddenState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Empty entry id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/provider/{provider}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -5553,6 +5626,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenState": {
|
||||
"type": "object",
|
||||
"description": "What `setLibraryEntryHidden` echoes back.",
|
||||
"required": [
|
||||
"id",
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Its visibility after the call."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The entry id the call addressed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HiddenToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryEntryHidden`.",
|
||||
"required": [
|
||||
"hidden"
|
||||
],
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this title should be hidden from every play surface."
|
||||
}
|
||||
}
|
||||
},
|
||||
"HookEntry": {
|
||||
"type": "object",
|
||||
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
|
||||
@@ -6339,6 +6443,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OperatorGameEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameEntry"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
|
||||
},
|
||||
"PairedClient": {
|
||||
"type": "object",
|
||||
"description": "A paired (certificate-pinned) Moonlight client.",
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
|
||||
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
|
||||
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
|
||||
#
|
||||
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
|
||||
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
|
||||
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
|
||||
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
|
||||
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
|
||||
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
|
||||
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
|
||||
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
|
||||
# backstop, not the plan.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
|
||||
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// later manual Back out of the library is not undone by a stale value.
|
||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
||||
val gamepadUi = gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
|
||||
@@ -67,7 +67,7 @@ class GamepadPalette(
|
||||
)
|
||||
|
||||
/**
|
||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
@@ -77,6 +77,22 @@ class GamepadPalette(
|
||||
ground = Triple(0.075, 0.060, 0.160),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// brand violet — focus has to be findable on black.
|
||||
"oled", "OLED",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
Triple(0.120, 0.024, 0.130),
|
||||
),
|
||||
ground = Triple(0.0, 0.0, 0.0),
|
||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||
),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
"nebula", "Nebula",
|
||||
|
||||
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
) + listOfNotNull(
|
||||
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||
// on a screen that is itself about to disappear.
|
||||
if (s.gamepadUiEnabled) {
|
||||
choice(
|
||||
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||
"With a controller: the touch interface comes back when the last one " +
|
||||
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||
) { update(s.copy(gamepadUiMode = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||
* the switch meant when it was a lone Boolean.
|
||||
*/
|
||||
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||
* always awake.
|
||||
*/
|
||||
const val GAMEPAD_UI_ALWAYS = "always"
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
|
||||
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
|
||||
* the only input, so it's always the console UI (as long as the setting is on), which is why the
|
||||
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||
* client wrote can never strand this one in a layout it has no way back out of.
|
||||
*/
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
fun gamepadUiActive(
|
||||
enabled: Boolean,
|
||||
mode: String,
|
||||
controllerConnected: Boolean,
|
||||
tv: Boolean,
|
||||
forced: Boolean,
|
||||
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
|
||||
@@ -94,11 +94,20 @@ data class Settings(
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||
*/
|
||||
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
@@ -107,9 +116,10 @@ data class Settings(
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
|
||||
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
|
||||
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
|
||||
* client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
||||
)
|
||||
}
|
||||
|
||||
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||
GAMEPAD_UI_ALWAYS to "Always",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
|
||||
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
SettingsGroup("Interface") {
|
||||
ToggleRow(
|
||||
title = "Controller-optimized UI",
|
||||
subtitle = "Switch to the console home when a controller is connected. A TV " +
|
||||
"always uses it.",
|
||||
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||
"gamepad chrome. A TV always uses it.",
|
||||
checked = s.gamepadUiEnabled,
|
||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||
)
|
||||
// Only decides anything while the switch above is on, so it is HIDDEN rather than
|
||||
// dimmed when it isn't — a picker whose every option changes nothing is worse than
|
||||
// no picker, and this group is short enough that nothing jumps far.
|
||||
if (s.gamepadUiEnabled) {
|
||||
SettingDropdown(
|
||||
label = "Show it",
|
||||
options = GAMEPAD_UI_MODE_OPTIONS,
|
||||
selected = s.gamepadUiMode,
|
||||
caption = "With a controller: the touch home comes back when the last one " +
|
||||
"disconnects. Always keeps the console home either way — for a device " +
|
||||
"that lives docked to a TV.",
|
||||
) { v -> update(s.copy(gamepadUiMode = v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||
),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||
assertEquals(6, firstLight)
|
||||
assertEquals(7, firstLight)
|
||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||
*/
|
||||
@Test
|
||||
fun oledIsActuallyBlack() {
|
||||
val oled = GamepadPalette.named("oled")
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||
val darkestOther = GamepadPalette.ALL
|
||||
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||
}
|
||||
|
||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||
@Test
|
||||
fun palettesAreHonestAboutLightness() {
|
||||
|
||||
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
|
||||
// Drawn as a switch, and reading the persisted default.
|
||||
assertEquals(true, row(on, "dsCapture").toggled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||
* be one last thing to step past on the way out.
|
||||
*/
|
||||
@Test
|
||||
fun `the activation-mode row follows the switch it belongs to`() {
|
||||
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||
Settings(gamepadUiEnabled = enabled),
|
||||
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) {}.map { it.id }
|
||||
|
||||
val on = ids(enabled = true)
|
||||
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||
assertEquals(
|
||||
"the mode belongs directly under the switch it qualifies",
|
||||
on.indexOf("gamepadUI") + 1,
|
||||
on.indexOf("gamepadUIMode"),
|
||||
)
|
||||
val off = ids(enabled = false)
|
||||
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||
}
|
||||
|
||||
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||
@Test
|
||||
fun `the activation-mode row steps the shared key`() {
|
||||
var s = Settings()
|
||||
fun mode() = buildSettingsRows(
|
||||
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertEquals("With a controller", mode().value)
|
||||
assertFalse("already the first = thud", mode().adjust(-1))
|
||||
assertTrue(mode().adjust(1))
|
||||
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||
// A from the last entry wraps home.
|
||||
mode().activate()
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||
* disagreement here is a device that behaves differently from the same setting.
|
||||
*/
|
||||
class GamepadUiTest {
|
||||
|
||||
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||
@Test
|
||||
fun whenConnectedWaitsForAPad() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||
}
|
||||
|
||||
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||
* way back to the touch UI. */
|
||||
@Test
|
||||
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||
* layout it has no way back out of. */
|
||||
@Test
|
||||
fun anUnknownModeWaitsForAPad() {
|
||||
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** The shipped default: the console UI still waits for a controller. */
|
||||
@Test
|
||||
fun theDefaultIsUnchangedBehaviour() {
|
||||
val s = Settings()
|
||||
assertTrue(s.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ class ProfilesTest {
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
|
||||
@@ -99,6 +99,10 @@ struct ContentView: View {
|
||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — "connected" (default) or "always". See
|
||||
/// `GamepadUIEnvironment`.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@@ -113,7 +117,8 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
|
||||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||||
@@ -901,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
|
||||
|
||||
@@ -85,16 +85,40 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
|
||||
/// gamepad screens' common root so no individual view has to read the setting.
|
||||
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
|
||||
/// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to
|
||||
/// everything below. Applied by the gamepad screens' common root so no individual view has to
|
||||
/// read the setting.
|
||||
///
|
||||
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
|
||||
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
|
||||
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
|
||||
/// palette's scheme would invert their own system colours instead of matching them.
|
||||
func gamepadPaletteInk(_ active: Bool = true) -> some View {
|
||||
modifier(GamepadInkModifier(active: active))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadInkModifier: ViewModifier {
|
||||
var active = true
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
/// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the
|
||||
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
|
||||
/// that would change this view's identity.
|
||||
@Environment(\.colorScheme) private var systemScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
return content
|
||||
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
|
||||
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
|
||||
// screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a
|
||||
// NavigationStack's title, a material's frost — resolves against the DEVICE's
|
||||
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
|
||||
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
|
||||
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
|
||||
// the interface was unreadable. Publishing the scheme here — once, beside the ink it
|
||||
// has to agree with — is what makes a pale palette mean "light" to UIKit too.
|
||||
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,12 @@ struct LibraryView: View {
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -78,6 +81,16 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Published HERE, not just inside the coverflow, because the coverflow is only one of
|
||||
// four things this view renders: the loading spinner, the error state and the empty
|
||||
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
|
||||
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
|
||||
// directly in a NavigationStack, so under a pale palette every one of them kept the
|
||||
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
|
||||
// UI isn't drawing — the plain grid belongs to the system background.
|
||||
.gamepadPaletteInk(gamepadUIActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
|
||||
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — the row is only built while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
// WHEN the switch above takes over. Built only while it is on: with the switch off this
|
||||
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
|
||||
// that decides nothing would exist purely to be found in a screenshot.
|
||||
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
|
||||
list.insert(
|
||||
choiceRow(
|
||||
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
|
||||
label: "Show it",
|
||||
detail: "With a controller: the touch interface comes back when the last one "
|
||||
+ "disconnects. Always keeps this layout either way — for a device that "
|
||||
+ "lives on a TV.",
|
||||
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
|
||||
) { gamepadUIMode = $0 },
|
||||
at: at + 1)
|
||||
}
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
|
||||
at: anchor + 1)
|
||||
}
|
||||
#endif
|
||||
// The smoothness buffer only decides anything under Smoothness. Every other settings
|
||||
// surface — touch, tvOS, the GTK and WinUI shells — hides it under Lowest latency; this
|
||||
// screen alone left it live and steppable, which is a row that thuds or silently stores
|
||||
// a value nothing reads. Removed here rather than omitted from the literal above so the
|
||||
// macOS safe-present insertion can still anchor on it.
|
||||
if presentPriority != "smooth" {
|
||||
list.removeAll { $0.id == "smoothBuffer" }
|
||||
}
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while
|
||||
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
|
||||
/// is off rather than showing a picker that decides nothing.
|
||||
static let gamepadUIModes: [(label: String, tag: String)] = [
|
||||
("With a controller", GamepadUIEnvironment.modeWhenConnected),
|
||||
("Always", GamepadUIEnvironment.modeAlways),
|
||||
]
|
||||
|
||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||
|
||||
@@ -724,11 +724,24 @@ extension SettingsView {
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
if !inProfileScope {
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
described("The host list and library switch to a controller-friendly layout — "
|
||||
+ "larger focus targets, a swipeable cover browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
}
|
||||
// Only meaningful while the switch above is on, so it is HIDDEN rather than
|
||||
// disabled when it isn't: a picker whose every option decides nothing is worse
|
||||
// than no picker, and this Section is short enough that nothing jumps far.
|
||||
if gamepadUIEnabled {
|
||||
described("With a controller: the touch interface comes back when the last "
|
||||
+ "one disconnects. Always keeps the controller-friendly layout either "
|
||||
+ "way — for a device that lives on a TV.") {
|
||||
Picker("Show it", selection: $gamepadUIMode) {
|
||||
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
|
||||
@@ -75,6 +75,13 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — read (and shown) only while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
|
||||
GamepadUIEnvironment.modeWhenConnected
|
||||
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) — every other
|
||||
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
|
||||
/// controller cannot open.
|
||||
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||
@@ -488,6 +495,22 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
// Hidden while the switch above is off — see the touch settings' identical gate.
|
||||
if gamepadUIEnabled {
|
||||
TVSelectionRow(
|
||||
title: "Show it",
|
||||
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
|
||||
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
|
||||
// Background row lives on the gamepad settings screen, which is reached from
|
||||
// the gamepad launcher — and on tvOS that launcher needs an extended-profile
|
||||
// controller, so an Apple TV driven by the Siri Remote alone could not reach
|
||||
// the palettes at all. It belongs beside "Show it" because both describe the
|
||||
// same interface: this row is what that interface looks like once it is up.
|
||||
TVSelectionRow(
|
||||
title: "Background",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
selection: $uiPalette)
|
||||
}
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("About") { AboutView() }
|
||||
.padding(.top, 8)
|
||||
|
||||
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
|
||||
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
|
||||
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
|
||||
// against the device appearance — which is how the pale palettes came out light-on-light
|
||||
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
|
||||
// tvOS and pre-26 branches were the odd ones out.
|
||||
#if os(tvOS)
|
||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||
content.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(materialWash) }
|
||||
.overlay {
|
||||
if let tint { shape.fill(tint) }
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
||||
in: shape)
|
||||
.environment(\.colorScheme, scheme)
|
||||
} else {
|
||||
content.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
|
||||
content
|
||||
.background {
|
||||
shape.fill(.regularMaterial)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ import os
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
///
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
|
||||
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
|
||||
/// long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches arrivals
|
||||
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
|
||||
/// constants here in step with `JitterTuning.COREAUDIO`.
|
||||
/// **Adaptive depth.** The target is a floor, not a constant: a NEAR-MISS — a read served with
|
||||
/// less than one frame left over — grows it a step BEFORE anything was audible, repeated genuine
|
||||
/// underruns grow it too (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`,
|
||||
/// and a long quiet spell relaxes it back toward the base — so a session on Wi-Fi that bunches
|
||||
/// arrivals deepens until it stops crackling, while a clean LAN keeps the tight base latency.
|
||||
/// Growth only raises a promise; the one thing that re-banks real depth is a re-prime, so an
|
||||
/// underrun while the ring is HOLLOW (depth average far below the target) re-primes at once,
|
||||
/// spending the click it already cost on the whole refill. Every shrink is armed as a PROBE:
|
||||
/// answered by an underrun or near-miss within its window, it is undone on the spot, and a
|
||||
/// failed sync-driven shrink is not retried for a growing backoff. Keep the constants here in
|
||||
/// step with `JitterTuning.COREAUDIO`.
|
||||
///
|
||||
/// **A/V sync.** On top of all that the depth can be STEERED, by `setSyncTarget` from the drain
|
||||
/// thread's `AvSync` — because a ring that is the right depth for the link is not thereby the
|
||||
@@ -58,9 +64,29 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// target normally relaxes only after a long spell because, absent other evidence, the only
|
||||
/// thing that can justify giving up hard-won slack is time; a sync request IS that evidence —
|
||||
/// a measurement saying the extra depth is costing alignment right now — so a smaller target
|
||||
/// gets tested sooner. Wrong guesses are cheap and self-correcting (one underrun and the
|
||||
/// growth path takes it straight back). Mirrors `SHRINK_QUIET_SYNC_MS`.
|
||||
/// gets tested sooner. Mirrors `SHRINK_QUIET_SYNC_MS`.
|
||||
private static let shrinkQuietSyncMS = 5_000
|
||||
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
|
||||
/// samples, but with less than one protocol frame left in hand — the same evidence as an
|
||||
/// underrun, except nobody heard it yet, so the target grows BEFORE the click instead of
|
||||
/// after the third one. Mirrors `NEAR_MISS_MARGIN_MS`.
|
||||
private static let nearMissMarginMS = frameMS
|
||||
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside
|
||||
/// this window means the shrink was wrong, and the previous target is restored at once.
|
||||
/// Mirrors `SHRINK_PROBE_MS`.
|
||||
private static let shrinkProbeMS = 5_000
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink — without it the
|
||||
/// loop pays an audible starvation event every `shrinkQuietSyncMS` on any link whose jitter
|
||||
/// genuinely needs the depth, forever. Doubles per consecutive failure, capped; a probe that
|
||||
/// survives its window resets it. Mirror `SYNC_BACKOFF_MS` / `SYNC_BACKOFF_MAX_MS`.
|
||||
private static let syncBackoffMS = 60_000
|
||||
private static let syncBackoffMaxMS = 480_000
|
||||
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: growth only ever
|
||||
/// raises the promise, and the one thing that re-banks real depth is a re-prime — so an
|
||||
/// underrun in a hollow ring re-primes AT ONCE, spending the click it already cost on the
|
||||
/// whole refill instead of riding the knife edge one click per bunching period. Mirrors
|
||||
/// `DEPRIME_DEBT_MS`.
|
||||
private static let deprimeDebtMS = growStepMS
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
@@ -87,6 +113,24 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// `nil` — the default, and what an un-wired session keeps — reproduces the pre-sync
|
||||
/// behaviour exactly, so this ring could adopt sync without the other three diverging.
|
||||
private var syncTarget: Int?
|
||||
/// This read was served with less than `nearMissMarginMS` left over (set in `read`,
|
||||
/// consumed by `noteRead`).
|
||||
private var nearMiss = false
|
||||
/// A near-miss already grew the target this window — one step per window, so a bunching
|
||||
/// episode (a RUN of consecutive near-misses while the ring refills) buys one measured
|
||||
/// step, not a sprint to the ceiling.
|
||||
private var nearMissGrown = false
|
||||
/// The depth average runs a `deprimeDebtMS` debt against the target (set in `read`): an
|
||||
/// underrun should re-prime at once instead of waiting out the hysteresis.
|
||||
private var hollow = false
|
||||
/// Interleaved samples left in the current shrink-probe window (0 = no probe outstanding).
|
||||
private var probeRun = 0
|
||||
/// The live target before the probed shrink, restored if the probe fails.
|
||||
private var probePrevTarget = 0
|
||||
/// Interleaved samples before the sync loop may drive another shrink (0 = allowed now).
|
||||
private var syncBackoffRun = 0
|
||||
/// Length of the NEXT backoff, in ms — doubles per consecutive failed probe, capped.
|
||||
private var syncBackoffLenMS = AudioRing.syncBackoffMS
|
||||
/// The sync loop's smoothed offset in ms, STORED not computed: the ring owns the depth but has
|
||||
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
|
||||
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
|
||||
@@ -121,8 +165,15 @@ final class AudioRing: @unchecked Sendable {
|
||||
/// then return the CAP — i.e. quietly below the continuity floor, inverting the very ordering
|
||||
/// this exists to guarantee, on exactly the awkward hardware it exists to survive. (Rust's
|
||||
/// `Ord::clamp` announces the same condition by panicking; Swift would just get it wrong.)
|
||||
private var target: Int {
|
||||
let floor = max(targetLive, renderQuantum + Self.frameMS * perMS)
|
||||
private var target: Int { target(lift: renderQuantum) }
|
||||
|
||||
/// The effective target with an explicit quantum lift. The property above uses the high-water
|
||||
/// `renderQuantum` (priming must survive the biggest callback seen); the hollow check in
|
||||
/// `read` passes the CURRENT callback instead, mirroring the Rust side's `want` — a one-off
|
||||
/// oversized read would otherwise inflate the debt threshold forever and turn the very next
|
||||
/// late packet into a full re-prime.
|
||||
private func target(lift quantum: Int) -> Int {
|
||||
let floor = max(targetLive, quantum + Self.frameMS * perMS)
|
||||
guard let want = syncTarget else { return floor }
|
||||
let cap = max(Self.hardCapMS * perMS, floor)
|
||||
return min(max(want, floor), cap)
|
||||
@@ -211,12 +262,24 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
// late packet would re-prime a ring that is actually full.
|
||||
depthAvg = Double(available)
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Hollow: the depth AVERAGE runs a debt against the target — the promise has been raised
|
||||
// but the depth was never re-banked (see `deprimeDebtMS`). Judged on the average, not
|
||||
// this instant: a single late packet empties the ring for a callback without making it
|
||||
// hollow, and must keep the consecutive-empties hysteresis. Lifted by THIS callback's
|
||||
// size, not the high-water quantum — see `target(lift:)`.
|
||||
hollow = depthAvg + Double(Self.deprimeDebtMS * perMS) < Double(target(lift: count))
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
@@ -240,6 +303,9 @@ final class AudioRing: @unchecked Sendable {
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
}
|
||||
// Near-miss: served in full, but with less than one frame left over — the next callback
|
||||
// starves unless a packet lands within one frame time.
|
||||
nearMiss = n == count && writeIdx - readIdx < Self.nearMissMarginMS * perMS
|
||||
noteRead(ranShort: n < count, count: count)
|
||||
}
|
||||
|
||||
@@ -254,32 +320,84 @@ final class AudioRing: @unchecked Sendable {
|
||||
if windowRun >= Self.growWindowMS * perMS {
|
||||
windowRun = 0
|
||||
underrunsInWindow = 0
|
||||
nearMissGrown = false
|
||||
}
|
||||
syncBackoffRun = max(0, syncBackoffRun - count)
|
||||
var restored = false
|
||||
if probeRun > 0 {
|
||||
probeRun = max(0, probeRun - count)
|
||||
if ranShort || nearMiss {
|
||||
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
|
||||
// Take the depth straight back — re-learning it three audible underruns at a
|
||||
// time is what made the sync-vs-growth tug-of-war audible — and keep the sync
|
||||
// loop from probing again for a while, doubling per consecutive failure. The
|
||||
// residual A/V offset is reported instead; continuity outranks sync. The
|
||||
// restore CONSUMES this event as growth evidence: it answered a depth the ring
|
||||
// is no longer at, so growing past the proven target on top would overshoot.
|
||||
probeRun = 0
|
||||
targetLive = max(targetLive, probePrevTarget)
|
||||
syncBackoffRun = syncBackoffLenMS * perMS
|
||||
syncBackoffLenMS = min(syncBackoffLenMS * 2, Self.syncBackoffMaxMS)
|
||||
restored = true
|
||||
} else if probeRun == 0 {
|
||||
// Survived the whole window: the shallower depth is genuinely safe here, so the
|
||||
// next probe starts from a clean slate.
|
||||
syncBackoffLenMS = Self.syncBackoffMS
|
||||
}
|
||||
}
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter {
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
}
|
||||
underrunsInWindow += 1
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
}
|
||||
if underrunsInWindow >= Self.growUnderruns {
|
||||
underrunsInWindow = 0
|
||||
windowRun = 0
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else if nearMiss {
|
||||
// Came within one frame of an underrun — the same evidence as one, heard by no one.
|
||||
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
|
||||
// the third audible underrun means the user heard two. One step per window (a
|
||||
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
|
||||
// measured step, not a sprint to the ceiling); if it worsens into real underruns
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
quietRun += count
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
// and without this branch a ring that ratcheted to the ceiling during a transient would
|
||||
// hold audio a ceiling's worth late for minutes after the cause had gone.
|
||||
let quietNeeded = syncWantsLess ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
|
||||
// hold audio a ceiling's worth late for minutes after the cause had gone. Every shrink
|
||||
// is armed as a PROBE — answered by an underrun or near-miss it is undone at once (see
|
||||
// above), and a failed sync-driven guess is not retried for a backoff.
|
||||
let syncShrink = syncWantsLess && syncBackoffRun == 0
|
||||
let quietNeeded = syncShrink ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
|
||||
if quietRun >= quietNeeded * perMS {
|
||||
quietRun = 0
|
||||
let prev = targetLive
|
||||
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
|
||||
if targetLive < prev {
|
||||
probeRun = Self.shrinkProbeMS * perMS
|
||||
probePrevTarget = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -3,20 +3,40 @@
|
||||
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
|
||||
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
|
||||
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
|
||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkShared
|
||||
|
||||
public enum GamepadUIEnvironment {
|
||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
||||
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
|
||||
/// and what the switch meant when it was a lone Bool.
|
||||
public static let modeWhenConnected = "connected"
|
||||
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad — asked
|
||||
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
|
||||
/// one they want and the pad is not always awake.
|
||||
public static let modeAlways = "always"
|
||||
|
||||
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) — off means
|
||||
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
|
||||
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
|
||||
/// value a newer client wrote) waits for a controller.
|
||||
///
|
||||
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
||||
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
|
||||
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
|
||||
/// without a real `GCController` (which XCTest can't construct).
|
||||
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
|
||||
enabledSetting && (gamepadConnected || forced)
|
||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
|
||||
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
|
||||
/// (which XCTest can't construct).
|
||||
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
|
||||
/// everyone who picked Always back on "only with a controller", which is exactly the bug
|
||||
/// this parameter exists to make impossible.
|
||||
public static func isActive(
|
||||
gamepadConnected: Bool,
|
||||
enabledSetting: Bool,
|
||||
mode: String
|
||||
) -> Bool {
|
||||
guard enabledSetting else { return false }
|
||||
return mode == modeAlways || gamepadConnected || forced
|
||||
}
|
||||
|
||||
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -176,16 +176,23 @@ public enum DefaultsKey {
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
|
||||
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default — only while a
|
||||
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
|
||||
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
|
||||
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
|
||||
/// settings rows hide it when the switch is off. Anything unrecognized reads as
|
||||
/// `"connected"`. A device preference, never part of a stream profile.
|
||||
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
|
||||
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
|
||||
/// console and the Android client carry the same table under the same names. Presentation
|
||||
/// only, so it is a device preference and never part of a stream profile. An unknown value
|
||||
/// reads as the default rather than failing — a newer client may have shipped a palette this
|
||||
/// build doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
|
||||
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
||||
]
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||
/// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way.
|
||||
public static let all: [GamepadPalette] = [
|
||||
// --- dark fields (white ink) ---
|
||||
GamepadPalette(
|
||||
id: "violet", name: "Violet", stops: [],
|
||||
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The first two stops are literally (0,0,0), so the shaded half of the
|
||||
// field is genuinely off rather than "very dark grey", and the ground is pure black
|
||||
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
||||
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||
// — focus has to be findable on black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
||||
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||
SIMD3(0.120, 0.024, 0.130)],
|
||||
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||
GamepadPalette(
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
|
||||
@@ -53,11 +53,22 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
/// The mirror case: a host clock running SLOW is a genuine deficit — no depth is ever deep
|
||||
/// enough forever — so the ring must spend it on RARE, clean re-banks (a hollow ring
|
||||
/// re-primes on its first click and refills the whole target) rather than riding the knife
|
||||
/// edge in permanent sub-frame chatter, which is what "silence-free" used to hide: every
|
||||
/// callback a fraction of a frame short, none of them fully silent, all of them audible.
|
||||
/// −200 ppm is an exaggeration of real DAC skew (tens of ppm); even so, two minutes may
|
||||
/// cost at most a couple of refills' worth of silent callbacks.
|
||||
func testNegativeDriftBanksRarelyInsteadOfChattering() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
XCTAssertLessThanOrEqual(
|
||||
silent, 24,
|
||||
"a draining ring re-banks a few times; a silent-callback stream means it is thrashing")
|
||||
XCTAssertGreaterThan(
|
||||
silent, 0,
|
||||
"a persistent deficit cannot be ridden out silence-free — if this is zero the ring "
|
||||
+ "is back to sub-frame chatter, which is audible without ever being silent")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
@@ -79,11 +90,14 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
// Drain it dry at the device's own quantum — an oversized read would count as ITS OWN
|
||||
// huge callback and legitimately read as hollow — then starve one callback and feed a
|
||||
// normal quantum again. The ring is freshly primed, so its depth average is nowhere near
|
||||
// hollow, and one short read must ride on the hysteresis.
|
||||
while ring.bufferedMS > 0 {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
@@ -92,14 +106,17 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
|
||||
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
|
||||
/// it back — and the floor never dips below the base.
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
/// a long — genuinely quiet — spell gives it back, never below the base. The quiet refill
|
||||
/// runs DEEP: a knife-edge refill (exactly what each read takes) leaves the ring within a
|
||||
/// frame of empty every callback, which now correctly reads as pressure, not quiet.
|
||||
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 25 * perMS)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
@@ -108,20 +125,27 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
|
||||
|
||||
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
|
||||
// each full read in between keeps the de-prime hysteresis from tripping.
|
||||
// Prime, then drain: the 5th read is still served in full but leaves nothing over — a
|
||||
// near-miss, and the floor grows BEFORE any click.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() } // drains to zero
|
||||
for _ in 0..<5 { read() }
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "a near-miss must grow the floor pre-click")
|
||||
XCTAssertEqual(ring.stats.underruns, 0, "nothing was audible yet")
|
||||
|
||||
// Then alternate starve/refill: each dry read is a genuine underrun, each full read in
|
||||
// between keeps the de-prime hysteresis from tripping. (The refills land as further
|
||||
// near-misses, but growth is one step per window — the cluster is what grows it again.)
|
||||
read() // short — underrun 1
|
||||
write(ms: 5); read() // full — hysteresis reset
|
||||
read() // short — underrun 2
|
||||
write(ms: 5); read() // full
|
||||
read() // short — underrun 3 → the floor grows one step
|
||||
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.targetMS, 40, "3 clustered underruns must grow the target 10 ms")
|
||||
XCTAssertEqual(ring.stats.underruns, 3)
|
||||
|
||||
// A long clean run (30 s of consumed audio) relaxes the growth back to the base…
|
||||
for _ in 0..<(30_000 / 5 + 10) {
|
||||
// A long clean run at a healthy depth relaxes the growth back to the base…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(90_000 / 5 + 10) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
@@ -435,10 +459,14 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
}
|
||||
/// Quiet (full) reads needed before the grown target relaxes one step.
|
||||
/// Quiet (full) reads needed before the grown target relaxes one step. The ring is
|
||||
/// refilled DEEP first: a knife-edge refill (exactly what each read takes) leaves less
|
||||
/// than a frame over every callback, which now correctly reads as pressure — near-misses
|
||||
/// — and pressure never relaxes anything.
|
||||
func quietToRelax(_ ring: AudioRing) -> Int {
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 5 * perMS)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 60 * perMS) }
|
||||
let start = ring.stats.targetMS
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == start, reads < 200_000 {
|
||||
@@ -466,6 +494,118 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"sync pressure should relax sooner: \(fastReads) vs \(slowReads) quiet reads")
|
||||
}
|
||||
|
||||
/// A shrink answered by an underrun or near-miss inside its probe window is undone AT ONCE,
|
||||
/// and the sync loop is backed off — mirrors the Rust `a_failed_shrink_probe_is_undone_at_once`
|
||||
/// and `a_failed_probe_backs_the_sync_shrink_off`. Before this, the loop re-probed a proven
|
||||
/// depth every five quiet seconds and paid an audible starvation event each time it was wrong,
|
||||
/// forever — the 0.25.0 MacBook field report.
|
||||
func testAFailedShrinkProbeIsUndoneAtOnceAndBacksTheSyncLoopOff() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor (near-miss + a cluster of genuine underruns), as the usual pattern does.
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
let grown = ring.stats.targetMS
|
||||
XCTAssertGreaterThan(grown, 20, "the test needs a GROWN floor to probe")
|
||||
|
||||
// Sync asks for less; a deep, genuinely quiet spell later the shrink probes.
|
||||
ring.setSyncTarget(perMS)
|
||||
write(ms: 60)
|
||||
var reads = 0
|
||||
while ring.stats.targetMS == grown, reads < 10_000 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
reads += 1
|
||||
}
|
||||
XCTAssertEqual(ring.stats.targetMS, grown - 10, "the sync-driven shrink must have probed")
|
||||
|
||||
// Drain to the knife edge: the last full read leaves nothing over — a near-miss, nobody
|
||||
// heard anything — and the probe must be undone on the spot.
|
||||
while ring.bufferedMS > 5 { read() }
|
||||
read()
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"a failed probe must restore the target on the first near-miss")
|
||||
XCTAssertEqual(ring.stats.underruns, 3, "and nothing audible may have paid for it")
|
||||
|
||||
// Backed off: two accelerated windows of clean, deep audio must NOT shrink again…
|
||||
write(ms: 60)
|
||||
for _ in 0..<(2 * 5_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
ring.stats.targetMS, grown,
|
||||
"the five-second cadence must be suspended after a failure")
|
||||
// …while the slow, pre-sync window eventually still tests one — backoff is not a freeze.
|
||||
for _ in 0..<(2 * 30_000 / 5) {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
XCTAssertLessThan(
|
||||
ring.stats.targetMS, grown,
|
||||
"the slow window must still be allowed to test a shrink")
|
||||
}
|
||||
|
||||
/// Growth raises a promise; only a re-prime banks real depth. An underrun while the ring is
|
||||
/// HOLLOW — its depth AVERAGE far below the target — re-primes immediately, spending the click
|
||||
/// it already cost on the whole refill, instead of riding the knife edge and clicking once per
|
||||
/// bunching period indefinitely. The average, not the instant, is what separates a hollow ring
|
||||
/// from one late packet (`testSingleShortReadDoesNotDeprime` pins that side).
|
||||
func testAHollowRingReprimesOnItsFirstClick() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
let feed = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
func write(ms: Int) {
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
|
||||
}
|
||||
func read() {
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
}
|
||||
// Grow the floor to 40 the usual way…
|
||||
write(ms: 25)
|
||||
for _ in 0..<5 { read() }
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
write(ms: 5); read()
|
||||
read()
|
||||
XCTAssertEqual(ring.stats.targetMS, 40)
|
||||
// …then ride the knife edge for ~2 s of audio, so the depth average genuinely sinks far
|
||||
// below the promised 40 ms.
|
||||
for _ in 0..<400 {
|
||||
write(ms: 5)
|
||||
read()
|
||||
}
|
||||
// One dry read — the click. The ring is hollow, so this single click must re-prime.
|
||||
read()
|
||||
// A packet arrives, but the ring stays SILENT: it is re-priming toward the full target
|
||||
// rather than playing the packet and clicking again at the next bunch.
|
||||
write(ms: 10)
|
||||
read()
|
||||
XCTAssertTrue(
|
||||
scratch.allSatisfy { $0 == 0 },
|
||||
"a hollow ring must spend its click on the whole refill, not keep limping")
|
||||
// And once the refill reaches the target, it plays again.
|
||||
write(ms: 40)
|
||||
read()
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "refilled to target — playback resumes")
|
||||
}
|
||||
|
||||
/// The four client rings adopt sync one at a time; an un-wired one must behave exactly as it
|
||||
/// did. `nil` is the default, so this pins the initializer too — and every other test in this
|
||||
/// file runs without a sync target, which is the real guard that nothing moved underneath them.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
||||
XCTAssertEqual(firstLight, 6)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
|
||||
XCTAssertEqual(firstLight, 7)
|
||||
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
/// not merely the darkest of the dark fields.
|
||||
func testOLEDIsActuallyBlack() {
|
||||
let oled = GamepadPalette.named("oled")
|
||||
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
|
||||
let cells = oled.meshColors
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
cells.filter { luma($0) == 0 }.count, 3,
|
||||
"the shaded corner has to be switched off, not dimmed")
|
||||
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
|
||||
let darkestOther = GamepadPalette.all
|
||||
.filter { $0.id != "oled" }
|
||||
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
|
||||
.min() ?? 0
|
||||
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
|
||||
}
|
||||
|
||||
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,58 @@
|
||||
// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs.
|
||||
// GamepadUIEnvironment.isActive is pure — table-tested exhaustively over its inputs.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadUIEnvironmentTests: XCTestCase {
|
||||
func testActiveOnlyWhenEnabledAndConnected() {
|
||||
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
|
||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
|
||||
private let connected = GamepadUIEnvironment.modeWhenConnected
|
||||
private let always = GamepadUIEnvironment.modeAlways
|
||||
|
||||
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
|
||||
/// that never sees the new row is exactly where it was.
|
||||
func testWhenConnectedIsAPlainAnd() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: connected))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: connected))
|
||||
}
|
||||
|
||||
/// Always drops the controller from the decision entirely — but NOT the switch, which stays
|
||||
/// the one way back to the touch UI.
|
||||
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: always))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: false, mode: always))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: false, mode: always))
|
||||
}
|
||||
|
||||
/// A value a newer client wrote must wait for a controller, never strand this build in a
|
||||
/// layout it has no way back out of.
|
||||
func testUnknownModeWaitsForAController() {
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertTrue(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||
XCTAssertFalse(
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: false, enabledSetting: true, mode: ""))
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
|
||||
+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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
|
||||
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
|
||||
// `Send + Sync` on their own). It is
|
||||
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
||||
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
||||
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1174,12 +1174,12 @@ pub struct Settings {
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
||||
/// why it is a device preference and never part of a settings profile. An unknown
|
||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
||||
/// palette this binary doesn't know.
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
|
||||
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
|
||||
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
|
||||
/// stream depends on it, which is why it is a device preference and never part of a
|
||||
/// settings profile. An unknown name reads as the default rather than erroring — a
|
||||
/// newer client may have shipped a palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
|
||||
-0.10, 0.08, -0.06, 0.12,
|
||||
];
|
||||
|
||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
|
||||
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
|
||||
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
|
||||
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
|
||||
/// gain the same entry to keep the `ui_palette` key portable.
|
||||
#[rustfmt::skip]
|
||||
pub const PALETTES: [Palette; 12] = [
|
||||
pub const PALETTES: [Palette; 13] = [
|
||||
// --- dark fields (white ink) ---
|
||||
Palette {
|
||||
id: "violet", name: "Violet", stops: None,
|
||||
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is pure
|
||||
// black too: the calm mix on the form screens lifts toward nothing, so settings and
|
||||
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
|
||||
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
|
||||
// luminance while keeping the backdrop a field with somewhere to go rather than a
|
||||
// dead rectangle. The accent stays the brand violet — focus has to be findable on
|
||||
// black.
|
||||
id: "oled", name: "OLED",
|
||||
stops: Some(&[
|
||||
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
|
||||
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
|
||||
]),
|
||||
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
|
||||
},
|
||||
Palette {
|
||||
// Deep indigo climbing through violet into a hot magenta.
|
||||
id: "nebula", name: "Nebula",
|
||||
@@ -857,7 +874,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||
"bloom", "dawn", "mint", "opal",
|
||||
]
|
||||
);
|
||||
@@ -867,7 +884,36 @@ mod tests {
|
||||
.position(|p| p.light)
|
||||
.expect("some are light");
|
||||
assert!(PALETTES[first_light..].iter().all(|p| p.light));
|
||||
assert_eq!(first_light, 6);
|
||||
assert_eq!(first_light, 7);
|
||||
}
|
||||
|
||||
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
|
||||
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
|
||||
/// under every other field's, and a ground that lifts to nothing on the form screens.
|
||||
#[test]
|
||||
fn oled_is_actually_black() {
|
||||
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
|
||||
let oled = palette("oled");
|
||||
assert_eq!(
|
||||
oled.ground,
|
||||
(0.0, 0.0, 0.0),
|
||||
"the calm lift must be nothing"
|
||||
);
|
||||
let cells = oled.mesh_colors();
|
||||
assert!(
|
||||
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
|
||||
"the shaded corner has to be switched off, not dimmed"
|
||||
);
|
||||
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
|
||||
let darkest_other = PALETTES
|
||||
.iter()
|
||||
.filter(|p| p.id != "oled")
|
||||
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
|
||||
.fold(f64::MAX, f64::min);
|
||||
assert!(
|
||||
mean < darkest_other / 2.0,
|
||||
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
|
||||
|
||||
@@ -258,11 +258,17 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
||||
/// profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
|
||||
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
|
||||
/// explainer placeholder while there are none.
|
||||
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
|
||||
if self.tab != PROFILES_TAB {
|
||||
return TABS[self.tab].1.to_vec();
|
||||
return TABS[self.tab]
|
||||
.1
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| row_applies(*id, ctx.settings))
|
||||
.collect();
|
||||
}
|
||||
if self.profiles.is_empty() {
|
||||
vec![RowId::NoProfiles]
|
||||
@@ -271,6 +277,16 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
|
||||
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
|
||||
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
|
||||
/// take it away between frames while this screen is open.
|
||||
fn clamp_cursor(&mut self, len: usize) {
|
||||
if self.list.cursor >= len {
|
||||
self.list.jump_to(len.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tab_for_test(&self) -> usize {
|
||||
self.tab
|
||||
@@ -278,21 +294,22 @@ impl SettingsScreen {
|
||||
|
||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||
/// value cycle), keeping each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
let n = TABS.len() as i32;
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
|
||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
|
||||
}
|
||||
|
||||
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
|
||||
/// a press on a pill names a tab outright rather than a direction to step in.
|
||||
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
|
||||
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
|
||||
if tab >= TABS.len() {
|
||||
return None;
|
||||
}
|
||||
self.tab_cursors[self.tab] = self.list.cursor;
|
||||
self.tab = tab;
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||
let len = self.row_ids().len();
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
|
||||
// Video's follows whether the smoothness buffer is offered.
|
||||
let len = self.row_ids(ctx).len();
|
||||
self.list
|
||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||
Some(MenuPulse::Move)
|
||||
@@ -302,10 +319,11 @@ impl SettingsScreen {
|
||||
/// there is never meant for a row.
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
if let Some(tab) = self.strip.pointer(p) {
|
||||
self.show_tab(tab);
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
@@ -325,11 +343,12 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||
}
|
||||
@@ -344,8 +363,14 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
// A cursor with no row under it can only mean the list shrank between the clamp above
|
||||
// and here, which nothing does today — but indexing on the assumption would turn that
|
||||
// into a panic in a shipping console rather than a dropped keypress.
|
||||
let Some(&focused) = ids.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
match focused {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
@@ -378,7 +403,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(focused, delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -388,7 +413,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(focused, 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -397,8 +422,8 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids();
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
hints.extend(match ids.get(self.list.cursor) {
|
||||
@@ -445,7 +470,8 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let ids = self.row_ids();
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
@@ -466,6 +492,24 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
|
||||
///
|
||||
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
|
||||
/// can see a line or two above them, so dimming them shows the relationship — dropping them
|
||||
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
|
||||
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
|
||||
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
|
||||
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
|
||||
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
|
||||
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
|
||||
/// so the cursor is never under anything that moves.
|
||||
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||
match id {
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
||||
// move everything under the cursor).
|
||||
// Two rows follow a switch a line or two above them: echo cancellation only means
|
||||
// anything while the mic streams, and the pad rows only while any controller is
|
||||
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
|
||||
// shells draw by greying a row out, and dimming (not dropping) is what shows the
|
||||
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
|
||||
// list instead now — see [`row_applies`] for why that one is different.
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||
}
|
||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
||||
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
|
||||
// is only reachable if another writer flipped the intent between the frame that built
|
||||
// the list and the keypress that lands here — a boundary thud, not a stored value
|
||||
// nothing will read.
|
||||
RowId::SmoothBuffer => {
|
||||
if s.present_priority == "smooth" {
|
||||
let cur = SMOOTH_BUFFERS
|
||||
@@ -1093,9 +1139,6 @@ mod tests {
|
||||
fake_home();
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
rendered(&mut s);
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids()[0], RowId::Resolution);
|
||||
let first = s.list.row_rect(0).expect("the list drew its rows");
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
|
||||
@@ -1109,6 +1152,9 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||
// window: one field, one unambiguous effect to assert on.
|
||||
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
|
||||
let mut fx = Outbox::default();
|
||||
assert!(!ctx.settings.match_window);
|
||||
assert!(s.pointer(press(first), &mut ctx, &mut fx));
|
||||
@@ -1232,13 +1278,12 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
||||
/// cursor.
|
||||
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||
/// always done; this screen was the exception until its row list stopped being fixed.
|
||||
#[test]
|
||||
fn smoothness_buffer_follows_the_intent() {
|
||||
fn smoothness_buffer_is_offered_only_under_smoothness() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
@@ -1251,24 +1296,93 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(
|
||||
!video.contains(&RowId::SmoothBuffer),
|
||||
"latency hides the buffer row: {video:?}"
|
||||
);
|
||||
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
|
||||
// Even reached out of band it writes nothing — the list it came from is a frame old.
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
);
|
||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
// Stepping the intent to Smoothness brings the row into the list, directly under it.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
let intent = video
|
||||
.iter()
|
||||
.position(|id| *id == RowId::PresentPriority)
|
||||
.expect("the intent row");
|
||||
assert_eq!(
|
||||
video.get(intent + 1),
|
||||
Some(&RowId::SmoothBuffer),
|
||||
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
|
||||
never has anything move out from under it"
|
||||
);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
// The intent wraps back and the row leaves again — with the cursor parked on the
|
||||
// intent row, which is where a user who just stepped it necessarily is.
|
||||
s.list.cursor = intent;
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
let video = s.row_ids(&ctx);
|
||||
assert!(!video.contains(&RowId::SmoothBuffer));
|
||||
assert_eq!(
|
||||
video.get(s.list.cursor),
|
||||
Some(&RowId::PresentPriority),
|
||||
"the cursor is still on the row the user was stepping"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
|
||||
/// than indexed with — the console must not panic because another writer changed the
|
||||
/// presentation intent while its settings screen was open.
|
||||
#[test]
|
||||
fn a_shrinking_list_pulls_the_cursor_back() {
|
||||
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
|
||||
// seated with the SHRUNKEN list's intent, which is the state being tested.
|
||||
fake_home();
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.present_priority = "latency".into();
|
||||
settings.save();
|
||||
settings.present_priority = "smooth".into();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = TABS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "Video")
|
||||
.expect("the Video tab");
|
||||
// Park on the last row while the buffer row is still there…
|
||||
s.list.cursor = s.row_ids(&ctx).len() - 1;
|
||||
let parked = s.list.cursor;
|
||||
// …then take it away behind the screen's back, as a desktop shell would.
|
||||
ctx.settings.present_priority = "latency".into();
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(pulse.is_some(), "the press was routed, not dropped");
|
||||
assert!(s.list.cursor < parked, "the cursor came back onto the list");
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1392,7 +1506,7 @@ mod tests {
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
@@ -1438,7 +1552,7 @@ mod tests {
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
let ids = s.row_ids(&ctx);
|
||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert!(!spec.enabled);
|
||||
|
||||
@@ -329,7 +329,7 @@ fn dump_console_screens() {
|
||||
for _ in 0..5 {
|
||||
s.handle_menu(MenuEvent::JumpForward);
|
||||
}
|
||||
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||
s.settings.ui_palette = id.to_string();
|
||||
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
|
||||
}
|
||||
|
||||
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 [
|
||||
|
||||
@@ -550,7 +550,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Some((x, y)) => b.position(x, y),
|
||||
None => b.position_centered(),
|
||||
};
|
||||
b.resizable().vulkan();
|
||||
// HIGH_PIXEL_DENSITY: give us a backbuffer in the panel's REAL pixels. Without it
|
||||
// SDL leaves the Wayland surface at buffer scale 1, so on a fractionally scaled
|
||||
// output (KDE at 150 %: a 2560×1600 panel reported as 1707×1067 points) the
|
||||
// swapchain is built at 1707×1067 and the compositor upscales it to the glass —
|
||||
// a 2560×1600 stream is resampled DOWN and back UP, and looks it. The flag only
|
||||
// widens `size_in_pixels()`; `size()` stays logical, which is what the persisted
|
||||
// window size and SDL's own mouse coordinates are in, and both callers already
|
||||
// use the right one.
|
||||
b.resizable().vulkan().high_pixel_density();
|
||||
if opts.fullscreen {
|
||||
b.fullscreen();
|
||||
}
|
||||
@@ -638,17 +646,29 @@ 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).
|
||||
let native = window
|
||||
.get_display()
|
||||
.and_then(|d| d.get_mode())
|
||||
.map(|m| Mode {
|
||||
width: m.w.max(0) as u32,
|
||||
height: m.h.max(0) as u32,
|
||||
refresh_hz: m.refresh_rate.round().max(0.0) as u32,
|
||||
})
|
||||
.map(|m| native_mode(m.w, m.h, m.pixel_density, m.refresh_rate))
|
||||
.ok()
|
||||
// A zero-sized mode is as useless as no mode at all — only `Err` used to reach
|
||||
// the fallback, so a display that reported 0×0 streamed a 0×0 request.
|
||||
.filter(|m: &Mode| m.width > 0 && m.height > 0)
|
||||
.unwrap_or(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -750,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()) {
|
||||
@@ -1062,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.
|
||||
@@ -2136,6 +2177,37 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// An `SDL_DisplayMode` as the panel's REAL pixels — the `0 = native` stream mode.
|
||||
///
|
||||
/// SDL3 reports a display mode in SCREEN COORDINATES, not pixels, and hands you the ratio
|
||||
/// between the two separately as `pixel_density`. On X11 and Windows that ratio is always
|
||||
/// 1.0 (SDL never sets it there, and `SDL_video.c` normalizes the unset 0.0 up to 1.0), so
|
||||
/// this is a no-op — but under a Wayland compositor doing FRACTIONAL scaling it is the
|
||||
/// whole ballgame: KDE at 150 % advertises a 2560×1600 panel as 1707×1067 points with
|
||||
/// `pixel_density` ≈ 1.4997, and taking `m.w`/`m.h` raw is what made "Native resolution"
|
||||
/// negotiate 1706×1066 (1707×1067 even-floored by `render_scale::apply`) and stream a
|
||||
/// blurry two-thirds-size image. `SDL_VIDEO_WAYLAND_SCALE_TO_DISPLAY=1` is the same fix
|
||||
/// from the outside — it makes SDL report the native mode itself — which is why setting it
|
||||
/// was a workaround.
|
||||
///
|
||||
/// The density is the exact `pixels / points` ratio SDL derived from the output, so the
|
||||
/// multiplication recovers the panel size to the pixel rather than approximating it.
|
||||
fn native_mode(w: i32, h: i32, pixel_density: f32, refresh_rate: f32) -> Mode {
|
||||
// A non-finite or non-positive density is SDL telling us nothing useful; 1× at least
|
||||
// preserves the pre-fix behaviour instead of collapsing the mode to zero.
|
||||
let density = if pixel_density.is_finite() && pixel_density > 0.0 {
|
||||
pixel_density
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let px = |v: i32| (v.max(0) as f32 * density).round().max(0.0) as u32;
|
||||
Mode {
|
||||
width: px(w),
|
||||
height: px(h),
|
||||
refresh_hz: refresh_rate.round().max(0.0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match-window (D1): replace the params' requested w/h with the window's physical pixel
|
||||
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
|
||||
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
|
||||
@@ -2908,6 +2980,52 @@ fn stats_text(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The field report this exists for: CachyOS/KDE Plasma 6.7.4 Wayland, a 2560×1600@165
|
||||
/// laptop panel at 150 % scaling. KDE advertises the output as 1707×1067 points, SDL
|
||||
/// hands that back as the desktop mode with `pixel_density` = 2560/1707, and "Native
|
||||
/// resolution" streamed 1706×1066 — the points, even-floored by `render_scale::apply`.
|
||||
#[test]
|
||||
fn native_is_the_panels_pixels_under_fractional_wayland_scaling() {
|
||||
// SDL derives the density as the exact pixels-per-point ratio of the output.
|
||||
let density = 2560.0 / 1707.0;
|
||||
let m = native_mode(1707, 1067, density, 165.0);
|
||||
assert_eq!((m.width, m.height, m.refresh_hz), (2560, 1600, 165));
|
||||
// …and it survives the even-floor the host's `validate_dimensions` forces, which is
|
||||
// where 1707×1067 lost its odd pixel and became the reported 1706×1066.
|
||||
assert_eq!(
|
||||
punktfunk_core::render_scale::apply(m.width, m.height, 1.0, 8192),
|
||||
(2560, 1600)
|
||||
);
|
||||
assert_eq!(
|
||||
punktfunk_core::render_scale::apply(1707, 1067, 1.0, 8192),
|
||||
(1706, 1066),
|
||||
"the pre-fix mode, kept here so the regression is legible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_is_unchanged_where_the_density_is_one() {
|
||||
// X11, Windows, and Wayland at 100 % all report 1.0 — the fix must be inert there.
|
||||
let m = native_mode(2560, 1600, 1.0, 165.0);
|
||||
assert_eq!((m.width, m.height, m.refresh_hz), (2560, 1600, 165));
|
||||
// Integer scaling (a 200 % 4K panel reported as 1920×1080 points) doubles cleanly.
|
||||
let m = native_mode(1920, 1080, 2.0, 60.0);
|
||||
assert_eq!((m.width, m.height), (3840, 2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonsense_density_falls_back_to_one_rather_than_zeroing_the_mode() {
|
||||
// SDL normalizes an unset density to 1.0, but this must not be the one place a
|
||||
// driver quirk can hand the host a 0×0 mode request.
|
||||
for bogus in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
let m = native_mode(2560, 1600, bogus, 60.0);
|
||||
assert_eq!((m.width, m.height), (2560, 1600), "density {bogus}");
|
||||
}
|
||||
// A negative mode size is clamped, not wrapped into a huge u32.
|
||||
let m = native_mode(-1, -1, 1.5, 60.0);
|
||||
assert_eq!((m.width, m.height), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_scale_follows_dpi_and_survives_a_bogus_display() {
|
||||
// 100 % / 96 dpi is the identity — the chrome keeps the size it always had.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -299,16 +299,21 @@ struct Pinger {
|
||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
|
||||
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
|
||||
/// the version handshake. Retired handles are deliberately kept alive — never closed — for the
|
||||
/// process lifetime: the pinger/linger threads and every capturer's `ChannelBroker` hold BARE
|
||||
/// `HANDLE` copies whose soundness contract is "never closed"; a retired handle only ever FAILS
|
||||
/// IOCTLs, which every holder already tolerates. Reopens are rare (a driver restart), so the retained
|
||||
/// list is bounded in practice.
|
||||
/// the version handshake.
|
||||
///
|
||||
/// Ownership is `Arc` all the way out: every consumer — `acquire`'s IOCTL runs, the pinger/linger
|
||||
/// threads, the capture layer's delivery closures — holds its OWN clone across its use, so retiring
|
||||
/// here merely drops the manager's reference and the handle CLOSES when the last in-flight user
|
||||
/// drains. That close is load-bearing, not housekeeping: an open control handle is exactly what
|
||||
/// vetoes the PnP disable/restart the wake-from-sleep recovery leans on (field 2026-08-08 — every
|
||||
/// reload REFUSED `Generic failure`; `reset-pf-vdisplay.ps1` stops the whole host service precisely
|
||||
/// to get its handles closed, and Arc ownership buys the same release without dying). The previous
|
||||
/// contract kept retired handles open for the process lifetime because bare `HANDLE` copies were
|
||||
/// smuggled into threads and closures; those copies are gone, and nothing may rely on a dead
|
||||
/// handle staying open again.
|
||||
#[derive(Default)]
|
||||
struct DeviceSlot {
|
||||
current: Option<Arc<OwnedHandle>>,
|
||||
/// Never dropped — see the type doc (bare-`HANDLE` holders rely on no-close).
|
||||
retired: Vec<Arc<OwnedHandle>>,
|
||||
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
|
||||
/// races sessions this process still considers live and must not raze them.
|
||||
opened_once: bool,
|
||||
@@ -397,11 +402,6 @@ pub fn vdm() -> &'static VirtualDisplayManager {
|
||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||
}
|
||||
|
||||
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: cached handles are never closed
|
||||
/// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy
|
||||
/// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
|
||||
/// handshake-latched protocol version, opening the control device once if no session has
|
||||
/// opened it yet this service run (the same open every session performs anyway) — so the
|
||||
@@ -421,7 +421,13 @@ pub fn hw_cursor_capable() -> bool {
|
||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||
}
|
||||
|
||||
pub fn control_device_handle() -> Option<HANDLE> {
|
||||
/// The live pf-vdisplay control device, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`) — an `Arc` clone the caller (and every closure it builds) holds for
|
||||
/// as long as it may issue IOCTLs: the handle stays open while any holder lives and closes when the
|
||||
/// last drains, which is what lets the wake-from-sleep recovery's PnP disable proceed once the
|
||||
/// manager retires it (see [`DeviceSlot`]). `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
pub fn control_device_handle() -> Option<Arc<OwnedHandle>> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
@@ -497,17 +503,28 @@ fn is_device_gone(e: &anyhow::Error) -> bool {
|
||||
GONE.contains(&w.code().0)
|
||||
}
|
||||
|
||||
/// The transient raw `HANDLE` view of an Arc-held control device, for the backend IOCTL surface.
|
||||
/// Sound only while the `Arc` it borrows from is held — which the borrow makes structural: every
|
||||
/// use site necessarily has the owning clone alive across the call, so a concurrent retire (which
|
||||
/// now really closes the handle once its users drain — see [`DeviceSlot`]) can never close it
|
||||
/// mid-IOCTL.
|
||||
fn dev_raw(dev: &OwnedHandle) -> HANDLE {
|
||||
HANDLE(dev.as_raw_handle())
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
pub(crate) fn backend_name(&self) -> &'static str {
|
||||
self.driver.name()
|
||||
}
|
||||
|
||||
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens.
|
||||
fn ensure_device(&self) -> Result<HANDLE> {
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens. Returns an
|
||||
/// `Arc` clone the caller holds across every IOCTL it derives from it — a concurrent retire then
|
||||
/// drops only the manager's reference and closes nothing under the caller (see [`DeviceSlot`]).
|
||||
fn ensure_device(&self) -> Result<Arc<OwnedHandle>> {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(d) = &slot.current {
|
||||
return Ok(HANDLE(d.as_raw_handle()));
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let reap = !slot.opened_once;
|
||||
claim_instance()?;
|
||||
@@ -519,35 +536,33 @@ impl VirtualDisplayManager {
|
||||
slot.opened_once = true;
|
||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||
let raw = HANDLE(handle.as_raw_handle());
|
||||
slot.current = Some(Arc::new(handle));
|
||||
let dev = Arc::new(handle);
|
||||
slot.current = Some(dev.clone());
|
||||
if !reap {
|
||||
tracing::info!("virtual-display control device reopened (retired handle replaced)");
|
||||
}
|
||||
Ok(raw)
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
/// The live control handle for the pinger/linger threads. `None` before the first acquire opened
|
||||
/// it, or between a retire and the next reopen.
|
||||
fn device_handle(&self) -> Option<HANDLE> {
|
||||
self.device
|
||||
.lock()
|
||||
.unwrap()
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|d| HANDLE(d.as_raw_handle()))
|
||||
/// The live control device for the pinger/linger threads — an `Arc` clone the caller holds
|
||||
/// across its IOCTLs. `None` before the first acquire opened it, or between a retire and the
|
||||
/// next reopen.
|
||||
fn device_handle(&self) -> Option<Arc<OwnedHandle>> {
|
||||
self.device.lock().unwrap().current.clone()
|
||||
}
|
||||
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure. The handle is retained
|
||||
/// un-closed (see [`DeviceSlot`]); the next [`ensure_device`](Self::ensure_device) reopens the
|
||||
/// (new) device interface and re-runs the version handshake.
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure: drop the manager's
|
||||
/// reference, so the handle CLOSES once the last in-flight user drains (see [`DeviceSlot`]) —
|
||||
/// the release the wake-from-sleep recovery needs before it can cycle the adapter devnode. The
|
||||
/// next [`ensure_device`](Self::ensure_device) reopens the (new) device interface and re-runs
|
||||
/// the version handshake.
|
||||
fn invalidate_device(&self, why: &anyhow::Error) {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(cur) = slot.current.take() {
|
||||
if slot.current.take().is_some() {
|
||||
tracing::warn!(
|
||||
"virtual-display control device retired — reopening on next use (cause: {why:#})"
|
||||
"virtual-display control device retired — closes when its last user drains, \
|
||||
reopening on next use (cause: {why:#})"
|
||||
);
|
||||
slot.retired.push(cur);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,11 +635,11 @@ impl VirtualDisplayManager {
|
||||
old_target,
|
||||
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
|
||||
// value `ensure_device()` returned above (cached handles are never closed — a dead one
|
||||
// is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; the `dev`
|
||||
// Arc `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
||||
@@ -657,11 +672,11 @@ impl VirtualDisplayManager {
|
||||
wudf_pid = mon.wudf_pid,
|
||||
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
||||
let _ = wait_target_departed(old_target, Duration::from_millis(400));
|
||||
}
|
||||
@@ -693,9 +708,10 @@ impl VirtualDisplayManager {
|
||||
else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
|
||||
// waits inside run under the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev, mon, mode) } {
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across
|
||||
// this call (so the handle stays open); the CCD waits inside run under
|
||||
// the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
|
||||
Ok(()) => {
|
||||
// Same join semantics as the re-arrival: +1 ref for the new
|
||||
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||
@@ -734,10 +750,11 @@ impl VirtualDisplayManager {
|
||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
|
||||
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across this call
|
||||
// (so the handle stays open); `re_add` touches the live topology under the held
|
||||
// `state` lock. `mon` is owned here (removed from the map).
|
||||
let new_mon = match unsafe {
|
||||
self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr)
|
||||
self.re_add(dev_raw(&dev), &mut inner, slot, &mon, mode, client_hdr)
|
||||
} {
|
||||
ReAdd::Arrived(m) => *m,
|
||||
ReAdd::RolledBack {
|
||||
@@ -815,11 +832,11 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
|
||||
// The slot is empty: create a fresh monitor for it.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is retired,
|
||||
// kept alive; see `DeviceSlot`), and we hold the `state` lock.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call (so the handle stays open even
|
||||
// against a concurrent retire), and we hold the `state` lock.
|
||||
let mon = match unsafe {
|
||||
self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
self.create_monitor(dev_raw(&dev), mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
} {
|
||||
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
|
||||
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
|
||||
@@ -831,9 +848,18 @@ impl VirtualDisplayManager {
|
||||
tracing::info!(
|
||||
"virtual-display control device reopened — retrying the monitor create"
|
||||
);
|
||||
// SAFETY: as above — `dev` is the handle the reopening `ensure_device` just
|
||||
// returned, and the `state` lock is still held.
|
||||
unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? }
|
||||
// SAFETY: as above — the `dev` Arc the reopening `ensure_device` just returned is
|
||||
// held across this call, and the `state` lock is still held.
|
||||
unsafe {
|
||||
self.create_monitor(
|
||||
dev_raw(&dev),
|
||||
mode,
|
||||
slot,
|
||||
client_hdr,
|
||||
hw_cursor,
|
||||
&mut inner,
|
||||
)?
|
||||
}
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
@@ -887,13 +913,12 @@ impl VirtualDisplayManager {
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
if let Some(h) = vdm().device_handle() {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. `h` is from
|
||||
// `device_handle()` (the `Some` branch) — cached handles are NEVER closed for the
|
||||
// process lifetime (a dead one is retired, kept alive; see `DeviceSlot`), so the
|
||||
// handle stays valid for this call even if it was retired concurrently — at worst
|
||||
// the IOCTL fails. The pinger thread only spins while the `&'static` manager
|
||||
// singleton lives.
|
||||
match unsafe { vdm().driver.ping(h) } {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open even if
|
||||
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
|
||||
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
|
||||
// while the `&'static` manager singleton lives.
|
||||
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
|
||||
Ok(()) => warned = false,
|
||||
Err(e) if is_device_gone(&e) => {
|
||||
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
|
||||
@@ -1897,12 +1922,11 @@ impl VirtualDisplayManager {
|
||||
slot,
|
||||
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev`
|
||||
// is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some`
|
||||
// checked above; cached handles are never closed — a dead one is retired, kept
|
||||
// alive). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; the
|
||||
// `dev` Arc from `device_handle()` (the `Some` checked above) is held across
|
||||
// this call, so the handle stays open. `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
}
|
||||
None => {
|
||||
inner.slots.insert(
|
||||
@@ -1980,10 +2004,10 @@ impl VirtualDisplayManager {
|
||||
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
|
||||
// `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()`
|
||||
// (the `Some` checked above). `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// the `dev` Arc from `device_handle()` (the `Some` checked above) is held
|
||||
// across this call, so the handle stays open. `mon` was moved out of the
|
||||
// map under the `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
|
||||
// acquire() Lingering-preempt settle).
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
@@ -2051,11 +2075,12 @@ impl VirtualDisplayManager {
|
||||
// its session. Lock order stays state → device (teardown's invalidate
|
||||
// path), same as every other holder; the pinger takes only the device
|
||||
// lock — no inversion.
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is
|
||||
// from `self.device_handle()` (cached handles are never closed — a dead
|
||||
// one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of
|
||||
// the map under the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev, &mut g, mon) };
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||
// Arc from `self.device_handle()` is held across this call, so the
|
||||
// handle stays open (a concurrent retire drops only the manager's
|
||||
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||
// the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2218,11 +2243,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
||||
inner.slots.remove(&k)
|
||||
{
|
||||
// SAFETY: `teardown_removed` needs a live control handle; `dev` is from
|
||||
// `device_handle()` (cached handles are never closed — a dead one is retired, kept
|
||||
// alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock,
|
||||
// so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` needs a live control handle; the `dev` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open (see
|
||||
// `DeviceSlot`). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
released += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,27 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
||||
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
||||
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
||||
/// failure is logged and swallowed. Returns the number removed.
|
||||
///
|
||||
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
|
||||
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
|
||||
/// a box with no ghosts produced byte-identical logs (silence) — the same vacuous-signal family as
|
||||
/// the `status=OK` trap [`reload_vdisplay_adapter`] answers — while ghosts ratcheted toward the
|
||||
/// wedge with every sleep cycle.
|
||||
fn reap_ghost_monitors() -> u32 {
|
||||
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
||||
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
||||
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
||||
//
|
||||
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
|
||||
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
|
||||
// System32 (and a SYSTEM process must not trust PATH anyway — a planted `pnputil.exe` would run
|
||||
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
|
||||
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
|
||||
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
|
||||
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output $n";
|
||||
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
|
||||
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output ($g.Count.ToString() + ' ' + $n)";
|
||||
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
||||
// System32 — with a bare-name fallback.
|
||||
let ps = std::env::var("SystemRoot")
|
||||
@@ -125,17 +138,29 @@ fn reap_ghost_monitors() -> u32 {
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let n = String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
if n > 0 {
|
||||
let raw = String::from_utf8_lossy(&o.stdout);
|
||||
let Some((found, removed)) = parse_reap_output(&raw) else {
|
||||
tracing::warn!(
|
||||
reaped = n,
|
||||
output = %raw.trim(),
|
||||
"pf-vdisplay: ghost-monitor reap died before reporting — ghost nodes (if any) still pin IddCx monitor slots"
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
if found == 0 {
|
||||
tracing::info!("pf-vdisplay: no ghost (not-present) virtual-monitor nodes to reap");
|
||||
} else if removed < found {
|
||||
tracing::warn!(
|
||||
found,
|
||||
removed,
|
||||
"pf-vdisplay: ghost-monitor reap could NOT remove every ghost node — the leftovers keep pinning IddCx monitor slots toward the 0x80070490 wedge"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
reaped = removed,
|
||||
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
||||
);
|
||||
}
|
||||
n
|
||||
removed
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
||||
@@ -144,6 +169,18 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse [`reap_ghost_monitors`]'s script output — `"<found> <removed>"`. Split out to be testable
|
||||
/// without a box, like [`classify_reload_output`]: the field failure this answers was a reap whose
|
||||
/// outcome could not be decoded from the log at all, so the decoding is worth pinning down. `None`
|
||||
/// = the script died before reporting (callers treat that as "removed nothing", loudly).
|
||||
fn parse_reap_output(out: &str) -> Option<(u32, u32)> {
|
||||
let mut it = out.split_whitespace().map(str::parse::<u32>);
|
||||
match (it.next(), it.next()) {
|
||||
(Some(Ok(found)), Some(Ok(removed))) => Some((found, removed)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
@@ -178,6 +215,14 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// The selector prefers LIVE devnodes: `Get-PnpDevice` also lists not-present PHANTOMS (an
|
||||
// upgrade/reinstall leftover), and the old `Select-Object -First 1` could hand every recovery
|
||||
// attempt a phantom — whose disable AND restart both fail — while a live node sat unexamined.
|
||||
// A phantom-only state gets its own truthful refusal: no reload lever can revive a devnode
|
||||
// record whose device is GONE; only re-creating the node (reinstall) can. `Present` is the
|
||||
// authoritative bit, with `Status -ne 'Unknown'` as the fallback should it read null; live
|
||||
// `OK` nodes sort ahead of problem-state ones.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
@@ -188,10 +233,19 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
//
|
||||
// The REFUSED line carries the evidence a field log needs to tell the failure modes apart
|
||||
// (2026-08-08: a woken box logged only `REFUSED Generic failure` — the WMI catch-all — leaving
|
||||
// handle-veto vs phantom vs problem-state undecidable): how many devnodes matched and how many
|
||||
// are live, the chosen node's PnP Status + ConfigManager problem code, and the pnputil
|
||||
// /restart-device exit code the old script threw away (3010 = needs a reboot, which is its own
|
||||
// diagnosis).
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
$all = @(Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' }); \
|
||||
if ($all.Count -eq 0) { Write-Output 'ABSENT'; exit }; \
|
||||
$live = @($all | Where-Object { $_.Present -or $_.Status -ne 'Unknown' } | Sort-Object { $_.Status -ne 'OK' }); \
|
||||
if ($live.Count -eq 0) { Write-Output ('REFUSED only phantom (not-present) adapter devnodes remain (' + $all.Count + ') - the device node itself is gone and no reload can revive it; reinstalling the host re-creates it'); exit }; \
|
||||
$ad = $live[0]; $id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
@@ -201,9 +255,11 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
$rx = $LASTEXITCODE; \
|
||||
if ($rx -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; \
|
||||
Write-Output ('REFUSED devnodes=' + $all.Count + ' live=' + $live.Count + ' status=' + $ad.Status + ' problem=' + $ad.ConfigManagerErrorCode + ' restart_exit=' + $rx + ' ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
@@ -1050,10 +1106,12 @@ const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock. The lock order is one-way —
|
||||
/// `RECOVERY` → `device`: the recovery's handle-release hooks (`invalidate_cached_device`, which
|
||||
/// drops the manager's reference so the control handle can CLOSE before the PnP cycle) take the
|
||||
/// `device` mutex while this is held. It must stay one-way: [`VdisplayDriver::open`] runs *inside*
|
||||
/// that same `device` mutex, so if it could also take this lock the two orders would invert and
|
||||
/// deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
@@ -1069,10 +1127,11 @@ pub fn ensure_available() -> Result<()> {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
// A reload tore the driver stack down and back up, so any control handle cached MEANWHILE (a
|
||||
// racing open during the arrival window) is dead by construction — retire it while we know
|
||||
// that for certain, rather than leaving the next session to discover it by having an IOCTL
|
||||
// fail. Usually a no-op now: the recovery path already released the manager's reference
|
||||
// before the reload (the handle-drain that lets the PnP cycle proceed at all).
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
@@ -1119,12 +1178,33 @@ fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedH
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
if absent_since.is_none() && reload {
|
||||
// First absent sighting on the recovery path: drop the manager's reference to the
|
||||
// (dead) control device NOW, so the ABSENT_SETTLE below doubles as the drain window
|
||||
// for every outstanding `Arc` clone — the handle then actually CLOSES before the
|
||||
// reload runs. An open control handle is exactly what vetoes the PnP disable (and
|
||||
// can wedge the pnputil restart) that the reload leans on; reset-pf-vdisplay.ps1
|
||||
// stops the whole host service to get the same release (field 2026-08-08: every
|
||||
// reload on a woken box came back REFUSED `Generic failure`). Gated on `reload`:
|
||||
// the BRIEF_RETRY caller runs inside the manager's `device` mutex, where taking it
|
||||
// again would deadlock — and that caller never reloads anyway.
|
||||
super::manager::invalidate_cached_device(
|
||||
"control interface absent — releasing the host's own device handle ahead of a \
|
||||
possible adapter reload",
|
||||
);
|
||||
}
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
// The not-ready path reaches here without the absent-sighting release above — drop the
|
||||
// manager's reference now for the same reason (idempotent: a second call is a no-op).
|
||||
super::manager::invalidate_cached_device(
|
||||
"adapter reload imminent — releasing the host's own device handle (open handles \
|
||||
veto the PnP cycle)",
|
||||
);
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
@@ -1195,6 +1275,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal must carry evidence, not just a verdict. The 2026-08-08 field log showed only
|
||||
/// `REFUSED Generic failure` — the WMI catch-all — leaving handle-veto vs phantom vs
|
||||
/// problem-state undecidable from the log. The enriched line's tokens (devnode counts, PnP
|
||||
/// status, problem code, the pnputil restart exit code the old script discarded) must survive
|
||||
/// decoding verbatim, and the phantom-only state must decode as a refusal too — a reload
|
||||
/// cannot revive a devnode record whose device is gone.
|
||||
#[test]
|
||||
fn a_refusal_keeps_its_evidence() {
|
||||
let why = match classify_reload_output(
|
||||
"REFUSED devnodes=2 live=1 status=OK problem=0 restart_exit=3010 Generic failure",
|
||||
) {
|
||||
AdapterCycle::Refused(why) => why,
|
||||
other => panic!("expected Refused, got {}", variant(&other)),
|
||||
};
|
||||
for token in ["devnodes=2", "live=1", "status=OK", "restart_exit=3010"] {
|
||||
assert!(why.contains(token), "{token} must survive: {why:?}");
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_reload_output(
|
||||
"REFUSED only phantom (not-present) adapter devnodes remain (2) - the device node \
|
||||
itself is gone and no reload can revive it; reinstalling the host re-creates it"
|
||||
),
|
||||
AdapterCycle::Refused(why) if why.contains("phantom")
|
||||
));
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
@@ -1226,6 +1332,29 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// The reap's outcome must decode losslessly — the field ratchet (0.23→0.25) was a reap whose
|
||||
/// bare-named pnputil never launched under the LocalSystem PATH while the host stayed silent:
|
||||
/// "no ghosts" and "removed nothing" were byte-identical. Found and removed now travel
|
||||
/// separately so a leftover ghost is loud, and the old single-number output (or a powershell
|
||||
/// that died before reporting) must not decode as anything.
|
||||
#[test]
|
||||
fn reap_output_decodes_found_and_removed() {
|
||||
assert_eq!(parse_reap_output("3 3\r\n"), Some((3, 3)));
|
||||
assert_eq!(
|
||||
parse_reap_output("4 0"),
|
||||
Some((4, 0)),
|
||||
"pnputil unlaunchable"
|
||||
);
|
||||
assert_eq!(parse_reap_output("0 0"), Some((0, 0)), "clean box");
|
||||
for dead in ["5", "", " ", "garbage", "OK"] {
|
||||
assert_eq!(
|
||||
parse_reap_output(dead),
|
||||
None,
|
||||
"{dead:?} is not a reap report"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +497,31 @@ const SHRINK_QUIET_MS: u32 = 30_000;
|
||||
/// The same, while the A/V sync loop is actively asking for a shallower ring — see the branch in
|
||||
/// [`JitterPolicy::note_read`] that selects between them.
|
||||
const SHRINK_QUIET_SYNC_MS: u32 = 5_000;
|
||||
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
|
||||
/// samples, but less than one protocol frame was left in hand, so the next callback starves
|
||||
/// unless a packet lands inside one frame time. On a healthy link the post-read depth hovers a
|
||||
/// whole target above this, which is what makes a near-miss evidence of real delivery jitter —
|
||||
/// the same evidence as an underrun, except nobody heard it yet.
|
||||
const NEAR_MISS_MARGIN_MS: u32 = FRAME_MS;
|
||||
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside this
|
||||
/// window means the shrink was wrong, and the previous target is restored at once instead of
|
||||
/// being re-learned three audible underruns at a time.
|
||||
const SHRINK_PROBE_MS: u32 = 5_000;
|
||||
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: the target promises a
|
||||
/// depth the ring does not actually hold. Growth only ever raises the promise — the one thing
|
||||
/// that re-banks real depth is a re-prime — so an underrun in a hollow ring re-primes AT ONCE:
|
||||
/// the click has already happened, and spending it on the whole refill is strictly better than
|
||||
/// riding the knife edge and paying a click per bunching period indefinitely, which is what the
|
||||
/// consecutive-empties hysteresis alone converges to. A full ring's underrun (one packet a few
|
||||
/// ms late) is nowhere near hollow and keeps the hysteresis.
|
||||
const DEPRIME_DEBT_MS: u32 = GROW_STEP_MS;
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink. Without this the
|
||||
/// loop pays an audible starvation event every [`SHRINK_QUIET_SYNC_MS`] on any link whose jitter
|
||||
/// genuinely needs the depth — sync asks for less, the ring shrinks, the link answers, the ring
|
||||
/// grows back, five quiet seconds later sync asks again, forever. Doubles per consecutive
|
||||
/// failure up to [`SYNC_BACKOFF_MAX_MS`]; a probe that survives its window resets it.
|
||||
const SYNC_BACKOFF_MS: u32 = 60_000;
|
||||
const SYNC_BACKOFF_MAX_MS: u32 = 480_000;
|
||||
|
||||
/// The playback de-jitter state machine shared by every client's audio ring.
|
||||
///
|
||||
@@ -539,6 +564,24 @@ pub struct JitterPolicy {
|
||||
/// behaviour exactly, which is what lets the four client rings adopt this one at a time
|
||||
/// without diverging in the meantime.
|
||||
sync_target: Option<usize>,
|
||||
/// Set by [`step`](Self::step) when the read it authorised leaves less than
|
||||
/// [`NEAR_MISS_MARGIN_MS`] buffered; consumed by [`note_read`](Self::note_read).
|
||||
near_miss: bool,
|
||||
/// A near-miss already grew the target this window — one step per window, so a single
|
||||
/// bunching episode (which lands as a RUN of consecutive near-misses while the ring refills)
|
||||
/// buys one measured step, not a sprint to the ceiling.
|
||||
near_miss_grown: bool,
|
||||
/// Set by [`step`](Self::step): the depth average sits more than [`DEPRIME_DEBT_MS`] below
|
||||
/// the target, so an underrun should re-prime at once instead of waiting out the hysteresis.
|
||||
hollow: bool,
|
||||
/// Consumed samples left in the current shrink-probe window (0 = no probe outstanding).
|
||||
probe_run: usize,
|
||||
/// The live target before the probed shrink, restored if the probe fails.
|
||||
probe_prev_target: usize,
|
||||
/// Consumed samples before the sync loop may drive another shrink (0 = allowed now).
|
||||
sync_backoff_run: usize,
|
||||
/// Length of the NEXT backoff, in ms — doubles per consecutive failed probe, capped.
|
||||
sync_backoff_ms: u32,
|
||||
}
|
||||
|
||||
impl JitterPolicy {
|
||||
@@ -558,6 +601,13 @@ impl JitterPolicy {
|
||||
quiet_run: 0,
|
||||
last_want: 0,
|
||||
sync_target: None,
|
||||
near_miss: false,
|
||||
near_miss_grown: false,
|
||||
hollow: false,
|
||||
probe_run: 0,
|
||||
probe_prev_target: 0,
|
||||
sync_backoff_run: 0,
|
||||
sync_backoff_ms: SYNC_BACKOFF_MS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +717,26 @@ impl JitterPolicy {
|
||||
if !self.primed && depth.saturating_sub(out.drop_front) >= target {
|
||||
self.primed = true;
|
||||
self.empties = 0;
|
||||
// The refill just banked this much: seed the average with it rather than letting it
|
||||
// climb from wherever the drought left it — a freshly-primed ring would otherwise
|
||||
// read as hollow for the EWMA's whole settling time, and the FIRST late packet
|
||||
// would re-prime a ring that is actually full.
|
||||
self.depth_avg = depth.saturating_sub(out.drop_front) as f32;
|
||||
}
|
||||
out.silence = !self.primed;
|
||||
// Near-miss: this read will be served, but with less than one frame left over — the
|
||||
// next callback starves unless a packet lands within one frame time. Unconditional
|
||||
// assignment, so a stale flag can never survive a de-prime into the next primed read.
|
||||
let after = depth.saturating_sub(out.drop_front);
|
||||
self.near_miss = self.primed
|
||||
&& after >= want
|
||||
&& after - want < NEAR_MISS_MARGIN_MS as usize * self.per_ms;
|
||||
// Hollow: the depth AVERAGE runs a debt against the target — the promise has been raised
|
||||
// but the depth was never re-banked (see `DEPRIME_DEBT_MS`). Judged on the average, not
|
||||
// this instant: a single late packet empties the ring for a callback without making it
|
||||
// hollow, and must keep the consecutive-empties hysteresis.
|
||||
self.hollow = self.primed
|
||||
&& (self.depth_avg as usize + DEPRIME_DEBT_MS as usize * self.per_ms) < target;
|
||||
out
|
||||
}
|
||||
|
||||
@@ -683,19 +751,51 @@ impl JitterPolicy {
|
||||
return;
|
||||
}
|
||||
let want = self.last_want.max(1);
|
||||
let near_miss = std::mem::take(&mut self.near_miss);
|
||||
self.window_run += want;
|
||||
if self.window_run >= GROW_WINDOW_MS as usize * self.per_ms {
|
||||
self.window_run = 0;
|
||||
self.underruns = 0;
|
||||
self.near_miss_grown = false;
|
||||
}
|
||||
self.sync_backoff_run = self.sync_backoff_run.saturating_sub(want);
|
||||
let mut restored = false;
|
||||
if self.probe_run > 0 {
|
||||
self.probe_run = self.probe_run.saturating_sub(want);
|
||||
if ran_short || near_miss {
|
||||
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
|
||||
// Take the depth straight back — re-learning it three audible underruns at a
|
||||
// time is what made the sync-vs-growth tug-of-war audible — and keep the sync
|
||||
// loop from probing again for a while, doubling per consecutive failure. The
|
||||
// residual A/V offset is reported instead; continuity outranks sync. The
|
||||
// restore CONSUMES this event as growth evidence: it answered a depth the ring
|
||||
// is no longer at, so growing past the proven target on top would overshoot.
|
||||
self.probe_run = 0;
|
||||
self.target = self.target.max(self.probe_prev_target);
|
||||
self.sync_backoff_run = self.sync_backoff_ms as usize * self.per_ms;
|
||||
self.sync_backoff_ms = (self.sync_backoff_ms * 2).min(SYNC_BACKOFF_MAX_MS);
|
||||
restored = true;
|
||||
} else if self.probe_run == 0 {
|
||||
// Survived the whole window: the shallower depth is genuinely safe here, so the
|
||||
// next probe starts from a clean slate.
|
||||
self.sync_backoff_ms = SYNC_BACKOFF_MS;
|
||||
}
|
||||
}
|
||||
if ran_short {
|
||||
self.quiet_run = 0;
|
||||
self.empties += 1;
|
||||
if self.empties >= self.tuning.deprime_after {
|
||||
if self.empties >= self.tuning.deprime_after || self.hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
self.primed = false;
|
||||
self.empties = 0;
|
||||
}
|
||||
self.underruns += 1;
|
||||
if !restored {
|
||||
self.underruns += 1;
|
||||
}
|
||||
if self.underruns >= GROW_UNDERRUNS {
|
||||
// This device genuinely needs more slack than the base target. Grow ONCE per
|
||||
// window, capped — the alternative (every device pre-paying the worst device's
|
||||
@@ -705,17 +805,33 @@ impl JitterPolicy {
|
||||
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
|
||||
self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms);
|
||||
}
|
||||
} else if near_miss {
|
||||
// Came within one frame of an underrun — the same evidence as one, heard by no one.
|
||||
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
|
||||
// the third audible underrun means the user heard two. One step per window (a
|
||||
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
|
||||
// measured step, not a sprint to the ceiling); if it worsens into real underruns
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
self.quiet_run = 0;
|
||||
self.empties = 0;
|
||||
if !self.near_miss_grown && !restored {
|
||||
self.near_miss_grown = true;
|
||||
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
|
||||
self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms);
|
||||
}
|
||||
} else {
|
||||
self.empties = 0;
|
||||
self.quiet_run += want;
|
||||
// A grown target normally relaxes only after a long quiet spell, because without other
|
||||
// evidence the only thing that can justify giving up hard-won slack is time. When the
|
||||
// sync loop is asking to run shallower it IS that evidence — a measurement saying the
|
||||
// extra depth is costing alignment right now — so test a smaller target sooner. Wrong
|
||||
// guesses are cheap and self-correcting: one underrun and the growth path takes it
|
||||
// straight back. Without this a ring that ratcheted to the ceiling during a transient
|
||||
// would hold the audio a ceiling's worth late for minutes after the cause had gone.
|
||||
let quiet_needed = if self.sync_wants_less() {
|
||||
// extra depth is costing alignment right now — so test a smaller target sooner. Every
|
||||
// shrink is armed as a PROBE: answered by an underrun or near-miss it is undone at
|
||||
// once (see above), and a failed sync-driven guess is not retried for a backoff —
|
||||
// without that, a link whose jitter genuinely needs the depth pays an audible
|
||||
// starvation event every five seconds, forever.
|
||||
let sync_shrink = self.sync_wants_less() && self.sync_backoff_run == 0;
|
||||
let quiet_needed = if sync_shrink {
|
||||
SHRINK_QUIET_SYNC_MS
|
||||
} else {
|
||||
SHRINK_QUIET_MS
|
||||
@@ -725,10 +841,15 @@ impl JitterPolicy {
|
||||
// doesn't cost latency for the rest of the session.
|
||||
self.quiet_run = 0;
|
||||
let base = self.tuning.base_target_ms as usize * self.per_ms;
|
||||
let prev = self.target;
|
||||
self.target = self
|
||||
.target
|
||||
.saturating_sub(GROW_STEP_MS as usize * self.per_ms)
|
||||
.max(base);
|
||||
if self.target < prev {
|
||||
self.probe_run = SHRINK_PROBE_MS as usize * self.per_ms;
|
||||
self.probe_prev_target = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1937,4 +2058,244 @@ mod tests {
|
||||
"sync pressure should relax sooner: {fast_reads} vs {slow_reads} quiet reads"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- near-miss growth and shrink probes (the audible-limit-cycle fixes) ---------------
|
||||
|
||||
/// A primed read that is served but leaves less than one frame buffered is a NEAR-MISS —
|
||||
/// the same evidence as an underrun, heard by no one — and must grow the target BEFORE the
|
||||
/// click, not after the third one. One step per window: a bunching episode lands as a run of
|
||||
/// consecutive near-misses while the ring refills, and must not sprint to the ceiling.
|
||||
#[test]
|
||||
fn a_near_miss_grows_the_target_without_an_underrun() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
p.step(t.base_target_ms as usize * pm, want); // primes exactly at target
|
||||
assert!(p.is_primed());
|
||||
let base = p.target_ms();
|
||||
// Serve the callback with less than one frame left over: depth = want + (margin − 1).
|
||||
p.step(want + NEAR_MISS_MARGIN_MS as usize * pm - 1, want);
|
||||
p.note_read(false); // NOT short — the device got its samples
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
base + GROW_STEP_MS,
|
||||
"a near-miss must buy one step"
|
||||
);
|
||||
// A second near-miss in the same window is the same episode: no further growth.
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), base + GROW_STEP_MS, "one step per window");
|
||||
// A healthy read does not grow anything.
|
||||
let grown = p.target_ms();
|
||||
p.step(grown as usize * pm + want, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), grown);
|
||||
}
|
||||
|
||||
/// A healthy steady depth must never read as a near-miss: the margin is one frame, and a
|
||||
/// ring hovering at target sits a whole target above it.
|
||||
#[test]
|
||||
fn steady_depth_never_grows_the_target() {
|
||||
let t = JitterTuning::PIPEWIRE;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
for _ in 0..(60_000 / 5) {
|
||||
// one minute of clean callbacks
|
||||
p.step(t.base_target_ms as usize * pm + want, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(p.target_ms(), t.base_target_ms);
|
||||
}
|
||||
|
||||
/// A shrink answered by an underrun (or near-miss) inside its probe window is undone AT
|
||||
/// ONCE — re-learning the depth three audible underruns at a time is what made the
|
||||
/// sync-vs-growth tug-of-war audible in the field.
|
||||
#[test]
|
||||
fn a_failed_shrink_probe_is_undone_at_once() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
// Grow the floor two steps the audible way.
|
||||
for _ in 0..(2 * GROW_UNDERRUNS) {
|
||||
while !p.is_primed() {
|
||||
p.step(200 * pm, want);
|
||||
}
|
||||
p.step(200 * pm, want);
|
||||
p.note_read(true);
|
||||
}
|
||||
let grown = p.target_ms();
|
||||
assert!(grown > t.base_target_ms);
|
||||
// Sync asks for less; five quiet seconds later the shrink probes.
|
||||
p.set_sync_target(Some(pm));
|
||||
let depth = grown as usize * pm + want;
|
||||
while p.target_ms() == grown {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(p.target_ms(), grown - GROW_STEP_MS);
|
||||
// ONE near-miss — nobody heard anything yet — and the depth is back.
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
grown,
|
||||
"a failed probe must restore the target on the first near-miss"
|
||||
);
|
||||
}
|
||||
|
||||
/// After a failed probe the sync loop may not drive another shrink at the accelerated
|
||||
/// cadence — the slow, pre-sync window still applies, the five-second one does not.
|
||||
#[test]
|
||||
fn a_failed_probe_backs_the_sync_shrink_off() {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
for _ in 0..(2 * GROW_UNDERRUNS) {
|
||||
while !p.is_primed() {
|
||||
p.step(200 * pm, want);
|
||||
}
|
||||
p.step(200 * pm, want);
|
||||
p.note_read(true);
|
||||
}
|
||||
let grown = p.target_ms();
|
||||
p.set_sync_target(Some(pm));
|
||||
let depth = grown as usize * pm + want;
|
||||
// First sync-driven shrink, then fail its probe.
|
||||
while p.target_ms() == grown {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
p.step(want + pm, want);
|
||||
p.note_read(false);
|
||||
assert_eq!(p.target_ms(), grown, "restored");
|
||||
// Twice the accelerated window of clean audio: the backed-off loop must NOT have
|
||||
// shrunk again (before the fix this was exactly one audible failure per five seconds).
|
||||
for _ in 0..(2 * SHRINK_QUIET_SYNC_MS / 5) {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert_eq!(
|
||||
p.target_ms(),
|
||||
grown,
|
||||
"the accelerated cadence must be suspended after a failure"
|
||||
);
|
||||
// The slow pre-sync window still relaxes it eventually — backoff is not a freeze.
|
||||
for _ in 0..(2 * SHRINK_QUIET_MS / 5) {
|
||||
p.step(depth, want);
|
||||
p.note_read(false);
|
||||
}
|
||||
assert!(
|
||||
p.target_ms() < grown,
|
||||
"the slow window must still be allowed to test a shrink"
|
||||
);
|
||||
}
|
||||
|
||||
/// One simulated bunching run's outcome.
|
||||
#[derive(Debug, Default)]
|
||||
struct BunchSim {
|
||||
/// Reads that actually starved the device — each one is audible.
|
||||
audible: u32,
|
||||
/// Audible reads in the second half of the run: non-zero means the policy never
|
||||
/// converged and the user hears it forever.
|
||||
audible_tail: u32,
|
||||
}
|
||||
|
||||
/// Drive a policy over a link that BUNCHES: delivery pauses for `gap_ms` every `period_ms`,
|
||||
/// then the withheld audio arrives at once — the Wi-Fi power-save pattern from the field
|
||||
/// reports, where the total rate is fine and only the spacing is wrong. `drift_ppm` is the
|
||||
/// host-vs-DAC clock skew; a slightly slow host (negative) erodes the depth over minutes,
|
||||
/// which is what keeps re-testing whatever target the policy has settled on — without it a
|
||||
/// simulated ring freezes wherever priming left it and a wrong target is never punished.
|
||||
fn simulate_bunching(
|
||||
tuning: JitterTuning,
|
||||
sync_target: Option<usize>,
|
||||
ms: u32,
|
||||
gap_ms: u32,
|
||||
period_ms: u32,
|
||||
drift_ppm: i64,
|
||||
) -> BunchSim {
|
||||
let pm = per_ms(2);
|
||||
let want = 5 * pm;
|
||||
let mut p = JitterPolicy::new(tuning, 2);
|
||||
p.set_sync_target(sync_target);
|
||||
let mut depth = 0usize;
|
||||
let mut withheld = 0usize;
|
||||
let mut carry: i64 = 0;
|
||||
let mut out = BunchSim::default();
|
||||
for cb in 0..(ms / 5) {
|
||||
// The host keeps producing (want ± drift per callback); the link decides delivery.
|
||||
carry += want as i64 * drift_ppm;
|
||||
let extra = carry / 1_000_000;
|
||||
carry -= extra * 1_000_000;
|
||||
let produced = (want as i64 + extra).max(0) as usize;
|
||||
let in_gap = (cb * 5) % period_ms < gap_ms;
|
||||
if in_gap {
|
||||
withheld += produced;
|
||||
} else {
|
||||
depth += produced + std::mem::take(&mut withheld);
|
||||
}
|
||||
let s = p.step(depth, want);
|
||||
depth -= s.drop_front.min(depth);
|
||||
if s.silence {
|
||||
p.note_read(false);
|
||||
continue;
|
||||
}
|
||||
let short = depth < want;
|
||||
depth -= want.min(depth);
|
||||
if short {
|
||||
out.audible += 1;
|
||||
if cb >= ms / 10 {
|
||||
out.audible_tail += 1;
|
||||
}
|
||||
}
|
||||
p.note_read(short);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// THE field regression this whole change is for. A link that bunches needs ~30 ms of ring;
|
||||
/// the sync loop wants less. Before this change the policy paid an audible event nearly
|
||||
/// every bunching period, indefinitely — this exact simulation measured ~2000 over ten
|
||||
/// minutes: the sync loop re-probed a proven depth every five quiet seconds, growth needed
|
||||
/// three audible underruns to answer, and a grown target was never re-banked (growth raises
|
||||
/// a threshold; only a re-prime deepens the ring), so the depth rode the knife edge. Now
|
||||
/// near-misses grow the target before the first click, a failed shrink probe is undone at
|
||||
/// once and backs the sync loop off, and a hollow ring cashes the whole refill on the click
|
||||
/// it already paid. What remains is the clock-skew re-anchor — a slightly slow host
|
||||
/// genuinely starves the ring every few minutes, and only rate adaptation (which no client
|
||||
/// has) could remove that — so the bound is "a handful over ten minutes", not zero.
|
||||
#[test]
|
||||
fn sync_pressure_on_a_bunching_link_converges_instead_of_clicking_forever() {
|
||||
// 25 ms gaps every 300 ms, a slightly slow host, ten minutes, sync permanently asking
|
||||
// for a 5 ms ring.
|
||||
let s = simulate_bunching(
|
||||
JitterTuning::COREAUDIO,
|
||||
Some(per_ms(2) * 5),
|
||||
600_000,
|
||||
25,
|
||||
300,
|
||||
-50,
|
||||
);
|
||||
assert!(
|
||||
s.audible_tail <= 4,
|
||||
"the tug-of-war must converge to the skew floor: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
s.audible <= 12,
|
||||
"learning the link may cost a handful of audible events, not a stream of them: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same link without sync pressure — the plain adaptive-growth behaviour — must land in
|
||||
/// the same place: sync steering may not add a persistent audible cost over not steering.
|
||||
#[test]
|
||||
fn a_bunching_link_without_sync_stays_clean_after_growing() {
|
||||
let s = simulate_bunching(JitterTuning::COREAUDIO, None, 600_000, 25, 300, -50);
|
||||
assert!(s.audible_tail <= 4, "{s:?}");
|
||||
assert!(s.audible <= 12, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@ mod epic;
|
||||
mod gog;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod heroic;
|
||||
mod hidden;
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod lutris;
|
||||
mod plugin_launch;
|
||||
mod scanners;
|
||||
mod steam;
|
||||
#[cfg(windows)]
|
||||
@@ -46,9 +48,11 @@ pub use epic::*;
|
||||
pub use gog::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use heroic::*;
|
||||
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)]
|
||||
@@ -195,6 +199,32 @@ pub struct GameEntry {
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
/// A library entry plus the operator's own view of it — today, whether they hid it.
|
||||
///
|
||||
/// A separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility
|
||||
/// answer out of the providers entirely: a store parser has no opinion on what the operator hid, and
|
||||
/// adding `hidden: false` to all eight construction sites would imply it does. More importantly it
|
||||
/// makes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers
|
||||
/// `Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired
|
||||
/// client by someone forgetting a filter; there is no field there to leak.
|
||||
///
|
||||
/// `flatten` keeps the wire shape identical to a plain entry with one extra key, so the console
|
||||
/// parses one model either way.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct OperatorGameEntry {
|
||||
#[serde(flatten)]
|
||||
pub entry: GameEntry,
|
||||
/// The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only
|
||||
/// grows for entries that actually are hidden.
|
||||
#[serde(skip_serializing_if = "is_not_hidden")]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// `skip_serializing_if` predicate for [`OperatorGameEntry::hidden`] — `&bool` as serde requires.
|
||||
fn is_not_hidden(hidden: &bool) -> bool {
|
||||
!*hidden
|
||||
}
|
||||
|
||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||
/// launchers; today only [`SteamProvider`] implements it.
|
||||
pub trait LibraryProvider {
|
||||
@@ -268,7 +298,39 @@ impl ArtKind {
|
||||
/// Removing the plugin releases the claim and the built-in comes straight back.
|
||||
///
|
||||
/// The user-curated custom store is not a source and always contributes.
|
||||
///
|
||||
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
|
||||
/// applied here rather than at each call site so a hidden title is gone from every surface by
|
||||
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
|
||||
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let hidden = hidden_ids();
|
||||
let mut games = collect_games();
|
||||
games.retain(|g| !hidden.contains(&g.id));
|
||||
games
|
||||
}
|
||||
|
||||
/// The library **including** the operator's hidden titles, each flagged.
|
||||
///
|
||||
/// The console's list is the only caller, and only on the operator's own lane (`GET /library`
|
||||
/// branches on it): a hidden entry has to be visible SOMEWHERE or it could never be brought back.
|
||||
/// Everything else — every paired client, the GameStream app list, launch resolution — goes through
|
||||
/// [`all_games`] and never sees them.
|
||||
pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
|
||||
let hidden = hidden_ids();
|
||||
collect_games()
|
||||
.into_iter()
|
||||
.map(|entry| OperatorGameEntry {
|
||||
hidden: hidden.contains(&entry.id),
|
||||
entry,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merge every enabled source + the custom entries, sorted by title — with no visibility gate of its
|
||||
/// own. Split out so the two public views above cannot drift: they differ only in what they do with
|
||||
/// the hidden set, never in what they collect.
|
||||
fn collect_games() -> Vec<GameEntry> {
|
||||
let off = disabled_scanners();
|
||||
let claimed = claimed_stores();
|
||||
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
|
||||
@@ -314,3 +376,90 @@ pub fn all_games() -> Vec<GameEntry> {
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, title: &str) -> GameEntry {
|
||||
GameEntry {
|
||||
id: id.into(),
|
||||
store: id.split_once(':').map_or("custom", |(s, _)| s).into(),
|
||||
title: title.into(),
|
||||
art: Artwork::default(),
|
||||
role: GameRole::default(),
|
||||
launch: None,
|
||||
provider: None,
|
||||
detect: DetectSpec::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console codes against this shape, so pin it: the operator view must be a normal entry
|
||||
/// with ONE extra key, and that key must vanish when the title is visible.
|
||||
///
|
||||
/// The skip matters beyond tidiness — it is what keeps this response byte-identical to the old
|
||||
/// one for a library with nothing hidden, so shipping the feature cannot change what an existing
|
||||
/// console renders until someone actually hides something.
|
||||
#[test]
|
||||
fn operator_entry_flattens_and_omits_hidden_when_false() {
|
||||
let visible = OperatorGameEntry {
|
||||
entry: entry("steam:70", "Half-Life"),
|
||||
hidden: false,
|
||||
};
|
||||
let v = serde_json::to_value(&visible).expect("serializes");
|
||||
assert_eq!(v["id"], "steam:70", "the entry's fields stay at top level");
|
||||
assert_eq!(v["title"], "Half-Life");
|
||||
assert!(
|
||||
v.get("hidden").is_none(),
|
||||
"a visible entry must not carry the key at all: {v}"
|
||||
);
|
||||
|
||||
let hidden = OperatorGameEntry {
|
||||
entry: entry("steam:70", "Half-Life"),
|
||||
hidden: true,
|
||||
};
|
||||
let v = serde_json::to_value(&hidden).expect("serializes");
|
||||
assert_eq!(v["hidden"], true);
|
||||
assert_eq!(v["id"], "steam:70", "flatten still applies when hidden");
|
||||
}
|
||||
|
||||
/// `all_games` and `all_games_for_operator` must agree on WHICH entries exist and differ only in
|
||||
/// visibility — they share `collect_games` for exactly that reason. This pins the shared-source
|
||||
/// property the same way the art test pins write/read symmetry: both views of an id-set built
|
||||
/// from one collector, so a future edit that inlines one of them is caught.
|
||||
#[test]
|
||||
fn hidden_filter_is_the_only_difference_between_the_two_views() {
|
||||
let games = vec![
|
||||
entry("steam:70", "Half-Life"),
|
||||
entry("lutris:4", "Syndicate"),
|
||||
entry("custom:abc", "Chrono Trigger"),
|
||||
];
|
||||
let hidden: HashSet<String> = ["lutris:4".to_string()].into_iter().collect();
|
||||
|
||||
let operator: Vec<OperatorGameEntry> = games
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| OperatorGameEntry {
|
||||
hidden: hidden.contains(&entry.id),
|
||||
entry,
|
||||
})
|
||||
.collect();
|
||||
let played: Vec<GameEntry> = games
|
||||
.into_iter()
|
||||
.filter(|g| !hidden.contains(&g.id))
|
||||
.collect();
|
||||
|
||||
assert_eq!(operator.len(), 3, "the operator sees every title");
|
||||
assert_eq!(played.len(), 2, "a player does not see the hidden one");
|
||||
assert!(
|
||||
!played.iter().any(|g| g.id == "lutris:4"),
|
||||
"the hidden id must be absent, not merely flagged"
|
||||
);
|
||||
assert_eq!(
|
||||
operator.iter().filter(|r| r.hidden).count(),
|
||||
1,
|
||||
"exactly the hidden one is flagged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,8 +350,21 @@ fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
|
||||
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
|
||||
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
|
||||
///
|
||||
/// A `file://` value is decoded to a plain path FIRST, exactly as [`local_art_bytes`] does. Both
|
||||
/// halves of the confinement must judge the *same* string or they disagree: `Path::new` on a raw
|
||||
/// `file:///home/u/c.jpg` yields a RELATIVE path whose first component is `file:`, which
|
||||
/// canonicalizes against the cwd, fails, and reads as "outside every root". That is not a
|
||||
/// conservative failure — it rejected every `file://` cover the plugin kit emits (`fileUrl`, the
|
||||
/// documented way for a library plugin to publish local art), so the Lutris and Steam scanners
|
||||
/// could not reconcile a single entry while the read path would have served those same files
|
||||
/// happily.
|
||||
pub fn art_path_is_servable(value: &str) -> bool {
|
||||
let p = Path::new(value);
|
||||
// Idempotent for the already-decoded caller: the decoded form no longer carries the prefix,
|
||||
// so `local_art_bytes` passing its own output back through here is a no-op, not a second
|
||||
// percent-decode of a path that legitimately contains `%`.
|
||||
let value = file_url_to_path(value);
|
||||
let p = Path::new(&*value);
|
||||
let ext_ok = p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -699,11 +712,23 @@ mod tests {
|
||||
|
||||
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
|
||||
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
|
||||
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
|
||||
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
|
||||
/// failure, not cascade into an unrelated `PoisonError`.
|
||||
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
|
||||
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
|
||||
/// plugin lane can write — so what it will and will not read IS the security boundary
|
||||
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
|
||||
#[test]
|
||||
fn local_art_bytes_is_confined_and_image_only() {
|
||||
let _guard = lock_art_roots();
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -837,6 +862,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The write gate and the read gate must judge the SAME string.
|
||||
///
|
||||
/// Regression for 2026-08-08: `validate_art_paths` handed the raw value to `Path::new`, so a
|
||||
/// `file:///…` cover became a *relative* path starting with a `file:` component, canonicalized
|
||||
/// against the cwd, failed, and was refused as "outside every art root" — while
|
||||
/// `local_art_bytes` decoded the very same value and served the file. Every Lutris and Steam
|
||||
/// entry carrying local art was rejected with a 400 the plugin could only report as
|
||||
/// `HostRequestError`, so neither scanner could sync a single game. Asserting servable and
|
||||
/// readable together is the point: either alone passes with the bug present.
|
||||
#[test]
|
||||
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
|
||||
let _guard = lock_art_roots();
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
|
||||
|
||||
let cover = dir.join("cover.png");
|
||||
std::fs::write(&cover, PNG).unwrap();
|
||||
|
||||
// What the kit's `fileUrl` actually emits for a Lutris/Steam cover.
|
||||
let url = file_url(&cover);
|
||||
assert!(
|
||||
is_local_art_path(&url),
|
||||
"a file:// value is local art, so the confinement applies to it"
|
||||
);
|
||||
assert!(
|
||||
art_path_is_servable(&url),
|
||||
"write time must accept the file:// form of a servable cover"
|
||||
);
|
||||
assert!(
|
||||
validate_art_paths(&Artwork {
|
||||
portrait: Some(url.clone()),
|
||||
header: Some(url),
|
||||
..Default::default()
|
||||
})
|
||||
.is_ok(),
|
||||
"a real Lutris-shaped payload must reconcile"
|
||||
);
|
||||
|
||||
// A percent-encoded name (the reason the decode exists at all) survives the round trip.
|
||||
let spaced = dir.join("My Cover.png");
|
||||
std::fs::write(&spaced, PNG).unwrap();
|
||||
let spaced_url = file_url(&spaced).replace(' ', "%20");
|
||||
assert!(
|
||||
art_path_is_servable(&spaced_url),
|
||||
"percent-encoded names must decode before the containment test: {spaced_url}"
|
||||
);
|
||||
assert!(local_art_bytes(&spaced_url).is_some(), "read time agrees");
|
||||
|
||||
// Loosening the write gate must not loosen the confinement: outside the root is still
|
||||
// refused in file:// clothing, which is what the raw-string bug was accidentally doing.
|
||||
let elsewhere = outside.join("cover.png");
|
||||
std::fs::write(&elsewhere, PNG).unwrap();
|
||||
assert!(
|
||||
!art_path_is_servable(&file_url(&elsewhere)),
|
||||
"file:// must not escape the art roots at write time either"
|
||||
);
|
||||
assert!(
|
||||
validate_art_paths(&Artwork {
|
||||
portrait: Some(file_url(&elsewhere)),
|
||||
..Default::default()
|
||||
})
|
||||
.is_err(),
|
||||
"an out-of-root file:// cover is still refused"
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Per-entry visibility: the operator hides one *title*, where `scanners.rs` hides a whole source.
|
||||
//!
|
||||
//! **Why this is a side table and not a field on the entry.** Only manual custom entries are stored;
|
||||
//! a scanner's and a plugin's titles are regenerated from scratch on every scan and every reconcile.
|
||||
//! A `hidden` flag written onto one of those would be erased by the next sync — silently, and
|
||||
//! minutes later, which is the worst possible shape for a setting. So the operator's choice lives
|
||||
//! here, keyed by the entry's stable `<store>:<external_id>` id, and the entries stay disposable.
|
||||
//!
|
||||
//! That id is stable *by construction* (design D2): a claimed store's entries keep
|
||||
//! `<store>:<external_id>` across reconciles no matter what the host-assigned id does, which is the
|
||||
//! same property GameStream app ids and client art caches already depend on. Hiding therefore
|
||||
//! survives a re-scan, a plugin restart, and the built-in→plugin migration for a store.
|
||||
//!
|
||||
//! Hiding is **curation, not access control** — it declutters a grid. It is applied in
|
||||
//! [`all_games`](crate::library::all_games), so a hidden title is gone from every play surface
|
||||
//! *including* launch resolution (the same reach a disabled scanner has), but nothing is deleted and
|
||||
//! un-hiding is immediate. The console is the one surface that still sees hidden titles — otherwise
|
||||
//! there would be no way to un-hide one — and only on the operator's own lane.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Persisted shape (`library-hidden.json`): the ids the operator hid. Absent file = nothing hidden.
|
||||
///
|
||||
/// Mirrors `library-scanners.json`'s disabled-set rather than sharing it: that file answers "which
|
||||
/// SOURCES run", this one answers "which TITLES show", and a source id (`steam`) and an entry id
|
||||
/// (`steam:70`) are different namespaces. Keeping them apart means neither migration can corrupt the
|
||||
/// other, and an operator reading either file sees one idea.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct HiddenSettings {
|
||||
#[serde(default)]
|
||||
hidden: Vec<String>,
|
||||
}
|
||||
|
||||
fn settings_path() -> PathBuf {
|
||||
// Same hardened config dir as library.json / library-scanners.json.
|
||||
pf_paths::config_dir().join("library-hidden.json")
|
||||
}
|
||||
|
||||
/// Load the hidden set (default + non-fatal if the file is absent or malformed).
|
||||
///
|
||||
/// A malformed file means "nothing hidden", never "hide everything": the failure mode of a bad parse
|
||||
/// must be a library that shows too much, not one that looks empty and reads as data loss.
|
||||
fn load_settings() -> HiddenSettings {
|
||||
match std::fs::read_to_string(settings_path()) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "library-hidden.json malformed — nothing hidden");
|
||||
HiddenSettings::default()
|
||||
}),
|
||||
Err(_) => HiddenSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(settings: &HiddenSettings) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(settings)?;
|
||||
// Write-then-rename like the catalog, so a crash mid-write never truncates the settings.
|
||||
let tmp = settings_path().with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, json.as_bytes())
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, settings_path()).context("rename library-hidden.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The hidden entry ids, loaded once per library read.
|
||||
pub(crate) fn hidden_ids() -> HashSet<String> {
|
||||
load_settings().hidden.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The store half of a library id (`steam:70` → `steam`), for the `library.changed` source.
|
||||
///
|
||||
/// Falls back to the whole id rather than an empty string: an id without a `:` is not a shape this
|
||||
/// host produces, and naming it in the event beats emitting a blank source that matches no cache key.
|
||||
fn store_of(id: &str) -> &str {
|
||||
id.split_once(':').map_or(id, |(store, _)| store)
|
||||
}
|
||||
|
||||
/// Hide or un-hide one entry. Returns whether the entry is hidden **after** the call.
|
||||
///
|
||||
/// Idempotent, and deliberately not validated against the current library: an entry can be absent
|
||||
/// right now for reasons that have nothing to do with the operator's intent — the launcher is closed,
|
||||
/// a plugin has not finished its first sync, a disk is unmounted. Refusing to hide a title that is
|
||||
/// temporarily missing, or silently dropping the choice when it comes back, would both be worse than
|
||||
/// storing an id that currently matches nothing. Persists and emits `library.changed` only when the
|
||||
/// state actually changed, so a repeated PUT is a cheap no-op.
|
||||
pub fn set_entry_hidden(id: &str, hidden: bool) -> Result<bool> {
|
||||
let mut settings = load_settings();
|
||||
let was_hidden = settings.hidden.iter().any(|h| h == id);
|
||||
if was_hidden == hidden {
|
||||
return Ok(hidden);
|
||||
}
|
||||
if hidden {
|
||||
settings.hidden.push(id.to_string());
|
||||
settings.hidden.sort();
|
||||
settings.hidden.dedup();
|
||||
} else {
|
||||
settings.hidden.retain(|h| h != id);
|
||||
}
|
||||
save_settings(&settings)?;
|
||||
crate::events::emit(crate::events::EventKind::LibraryChanged {
|
||||
source: store_of(id).to_string(),
|
||||
});
|
||||
Ok(hidden)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The event source is the STORE, not the whole id — that is the key every client cache and the
|
||||
/// console's query invalidation is grouped by.
|
||||
#[test]
|
||||
fn store_of_takes_the_prefix_and_tolerates_a_bare_id() {
|
||||
assert_eq!(store_of("steam:70"), "steam");
|
||||
assert_eq!(store_of("custom:abc"), "custom");
|
||||
// An external id may itself contain a colon (Heroic's `legendary:<hash>`): split on the
|
||||
// FIRST one, or the store would come back wrong for exactly the store that does this.
|
||||
assert_eq!(store_of("heroic:legendary:fc0b13b7"), "heroic");
|
||||
assert_eq!(store_of("weird-no-colon"), "weird-no-colon");
|
||||
}
|
||||
|
||||
/// A malformed settings file must read as "nothing hidden". The inverse — treating a parse
|
||||
/// failure as "hide everything" — would present as a library that lost its games.
|
||||
#[test]
|
||||
fn malformed_settings_hide_nothing() {
|
||||
let s: HiddenSettings = serde_json::from_str("{ not json").unwrap_or_default();
|
||||
assert!(s.hidden.is_empty());
|
||||
let s: HiddenSettings = serde_json::from_str("{}").expect("an empty object is valid");
|
||||
assert!(s.hidden.is_empty(), "absent key means nothing hidden");
|
||||
}
|
||||
|
||||
/// The persisted shape is the contract an operator may hand-edit — pin it.
|
||||
#[test]
|
||||
fn settings_roundtrip_the_documented_shape() {
|
||||
let s: HiddenSettings =
|
||||
serde_json::from_str(r#"{"hidden":["steam:70","lutris:4"]}"#).expect("parses");
|
||||
assert_eq!(s.hidden, vec!["steam:70", "lutris:4"]);
|
||||
let json = serde_json::to_string(&s).expect("serializes");
|
||||
assert_eq!(json, r#"{"hidden":["steam:70","lutris:4"]}"#);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -844,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() {
|
||||
@@ -890,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" => {
|
||||
@@ -900,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);
|
||||
@@ -934,6 +947,7 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
|
||||
out,
|
||||
loopback,
|
||||
wire_chunk,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1007,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;
|
||||
@@ -228,6 +236,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(library::get_library))
|
||||
.routes(routes!(library::list_library_scanners))
|
||||
.routes(routes!(library::set_library_scanner))
|
||||
.routes(routes!(library::set_library_entry_hidden))
|
||||
.routes(routes!(library::create_custom_game))
|
||||
.routes(routes!(
|
||||
library::update_custom_game,
|
||||
|
||||
@@ -51,6 +51,18 @@ impl AuthLane {
|
||||
pub(crate) fn may_set_privileged_fields(self) -> bool {
|
||||
matches!(self, AuthLane::Admin)
|
||||
}
|
||||
|
||||
/// Whether this is the operator's own lane — the console, as opposed to a paired client or a
|
||||
/// plugin.
|
||||
///
|
||||
/// Same arm as [`may_set_privileged_fields`](Self::may_set_privileged_fields) today, and
|
||||
/// deliberately a separate question: that one asks "may this caller cause command execution",
|
||||
/// this one asks "is this caller the person curating the library". A read-only view the operator
|
||||
/// alone should see (their hidden titles) is not a privilege escalation, and collapsing the two
|
||||
/// would leave whichever one changes first silently answering for the other.
|
||||
pub(crate) fn is_operator(self) -> bool {
|
||||
matches!(self, AuthLane::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
|
||||
|
||||
@@ -14,33 +14,42 @@ use axum::Extension;
|
||||
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
|
||||
/// authority alone. Route reachability and field authority are separate questions.
|
||||
///
|
||||
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
|
||||
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
|
||||
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
|
||||
/// `Some((reason, response))` is the refusal to return; `None` means the payload may proceed.
|
||||
/// Deliberately not `Result<(), Response>`: the "error" here IS the response the handler sends, so
|
||||
/// there is no error value to propagate, and a 128-byte `Response` in an `Err` variant is what
|
||||
/// `clippy::result_large_err` objects to.
|
||||
///
|
||||
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
|
||||
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
|
||||
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
|
||||
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
|
||||
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
|
||||
fn check_entry_fields(
|
||||
lane: AuthLane,
|
||||
art: &crate::library::Artwork,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
) -> Option<Response> {
|
||||
) -> Option<(String, Response)> {
|
||||
if !lane.may_set_privileged_fields() {
|
||||
if let Some(field) = crate::library::privileged_field(launch, prep) {
|
||||
return Some(api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
&format!(
|
||||
"`{field}` is executed as the host user and may only be set with the \
|
||||
operator's admin token — a plugin may publish entries with any host-resolved \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
return Some((
|
||||
format!("payload carries `{field}`, which this lane may not set"),
|
||||
api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
&format!(
|
||||
"`{field}` is executed as the host user and may only be set with the \
|
||||
operator's admin token — a plugin may publish entries with any host-resolved \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
|
||||
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -58,6 +67,10 @@ pub(crate) struct LibraryQuery {
|
||||
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||
///
|
||||
/// **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying
|
||||
/// `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The
|
||||
/// console needs them to offer "un-hide", and it is the only surface that does.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library",
|
||||
@@ -68,26 +81,28 @@ pub(crate) struct LibraryQuery {
|
||||
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||
(status = OK, description = "Unified library across all stores (the operator's lane also gets hidden entries, flagged)", body = [crate::library::OperatorGameEntry]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_library(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
Query(q): Query<LibraryQuery>,
|
||||
) -> Json<Vec<crate::library::GameEntry>> {
|
||||
) -> Response {
|
||||
// The operator's list is a DIFFERENT TYPE, not the same one with a flag set — which is what
|
||||
// makes "a hidden title never reaches a paired client" structural rather than a filter someone
|
||||
// has to remember. The redaction below is skipped here because this arm is the operator's own
|
||||
// token: the command line being redacted is the one they typed.
|
||||
if lane.is_operator() {
|
||||
let mut rows = crate::library::all_games_for_operator();
|
||||
rows.retain(|r| matches_query(&r.entry, &q));
|
||||
for r in &mut rows {
|
||||
crate::library::proxy_local_art(&r.entry.id, &mut r.entry.art);
|
||||
}
|
||||
return Json(rows).into_response();
|
||||
}
|
||||
let mut games = crate::library::all_games();
|
||||
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||
}
|
||||
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| {
|
||||
g.meta
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
|
||||
});
|
||||
}
|
||||
games.retain(|g| matches_query(g, &q));
|
||||
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
||||
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
||||
// library size, and the client never sees an unreachable `C:\…`).
|
||||
@@ -103,16 +118,97 @@ pub(crate) async fn get_library(
|
||||
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
|
||||
// which is the invariant that stops a client injecting a command in the first place. The
|
||||
// `kind` stays, so "this is launchable, and how" still renders.
|
||||
if !lane.may_set_privileged_fields() {
|
||||
for g in &mut games {
|
||||
if let Some(l) = g.launch.as_mut() {
|
||||
if l.kind == "command" {
|
||||
l.value.clear();
|
||||
}
|
||||
//
|
||||
// Unconditional now: the operator's lane returned above, so reaching here IS "some lane but
|
||||
// theirs". Leaving the old `if !lane.may_set_privileged_fields()` would read as though an
|
||||
// unredacted path still existed here, and would quietly stop redacting if that early return
|
||||
// ever moved.
|
||||
for g in &mut games {
|
||||
if let Some(l) = g.launch.as_mut() {
|
||||
if l.kind == "command" {
|
||||
l.value.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(games)
|
||||
Json(games).into_response()
|
||||
}
|
||||
|
||||
/// The `?provider=` / `?platform=` narrowing, shared by both lane arms so they cannot drift.
|
||||
fn matches_query(g: &crate::library::GameEntry, q: &LibraryQuery) -> bool {
|
||||
if let Some(provider) = q.provider.as_deref().filter(|p| !p.is_empty()) {
|
||||
if g.provider.as_deref() != Some(provider) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(platform) = q.platform.as_deref().filter(|p| !p.is_empty()) {
|
||||
if !g
|
||||
.meta
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(platform))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Request body for `setLibraryEntryHidden`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct HiddenToggle {
|
||||
/// Whether this title should be hidden from every play surface.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// What `setLibraryEntryHidden` echoes back.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HiddenState {
|
||||
/// The entry id the call addressed.
|
||||
id: String,
|
||||
/// Its visibility after the call.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// Hide or un-hide one library title
|
||||
///
|
||||
/// Curation, not access control: a hidden title disappears from every play surface — the console
|
||||
/// grid on a client, native clients, the GameStream app list, and launch resolution — while nothing
|
||||
/// is deleted and un-hiding restores it immediately. The operator's own console still lists it
|
||||
/// (flagged `hidden`) so it can be brought back.
|
||||
///
|
||||
/// Keyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by
|
||||
/// construction (D2). The id is **not** validated against the current library on purpose: a title
|
||||
/// can be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),
|
||||
/// and refusing the operator's choice in that window would be worse than storing an id that
|
||||
/// currently matches nothing. Emits `library.changed` (source = the store) only on a real change.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/hidden/{id}",
|
||||
tag = "library",
|
||||
operation_id = "setLibraryEntryHidden",
|
||||
params(("id" = String, Path, description = "The library entry id (e.g. `steam:70`)")),
|
||||
request_body = HiddenToggle,
|
||||
responses(
|
||||
(status = OK, description = "Stored; the entry's visibility after the call", body = HiddenState),
|
||||
(status = BAD_REQUEST, description = "Empty entry id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_library_entry_hidden(
|
||||
Path(id): Path<String>,
|
||||
ApiJson(toggle): ApiJson<HiddenToggle>,
|
||||
) -> Response {
|
||||
if id.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "entry id must not be empty");
|
||||
}
|
||||
match crate::library::set_entry_hidden(&id, toggle.hidden) {
|
||||
Ok(hidden) => {
|
||||
tracing::info!(entry = %id, hidden, "management API: library entry visibility set");
|
||||
Json(HiddenState { id, hidden }).into_response()
|
||||
}
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for `setLibraryScanner`.
|
||||
@@ -205,7 +301,9 @@ pub(crate) async fn create_custom_game(
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
match crate::library::add_custom(input) {
|
||||
@@ -238,7 +336,9 @@ pub(crate) async fn update_custom_game(
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
use crate::library::MutateOutcome;
|
||||
@@ -364,11 +464,14 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
|
||||
// one privileged field anywhere in it is one command execution.
|
||||
for (i, e) in inputs.iter().enumerate() {
|
||||
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
|
||||
if let Some((reason, denied)) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep)
|
||||
{
|
||||
tracing::warn!(
|
||||
provider,
|
||||
index = i,
|
||||
"library reconcile refused: payload carries a field this lane may not set"
|
||||
title = %e.title,
|
||||
reason = %reason,
|
||||
"library reconcile refused"
|
||||
);
|
||||
return denied;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1198,6 +1198,10 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
|
||||
("GET", "/api/v1/library/scanners", true, false),
|
||||
("PUT", "/api/v1/library/scanners/{id}", true, false),
|
||||
// Hiding a title is the OPERATOR curating their own library: a plugin has no business
|
||||
// deciding what the operator sees, and a paired client must not be able to hide a game on
|
||||
// the host it is streaming from. Neither lane, unlike the scanner toggle above.
|
||||
("PUT", "/api/v1/library/hidden/{id}", false, false),
|
||||
("POST", "/api/v1/library/custom", true, false),
|
||||
("PUT", "/api/v1/library/custom/{id}", true, false),
|
||||
("DELETE", "/api/v1/library/custom/{id}", true, false),
|
||||
@@ -2048,6 +2052,40 @@ async fn library_scanner_list_and_unknown_toggle() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A library id is `<store>:<external_id>`, so the hide route's path segment CONTAINS A COLON —
|
||||
/// and for Heroic (`heroic:legendary:<hash>`) it contains two.
|
||||
///
|
||||
/// This is the one thing about the endpoint that could be silently wrong: if the router did not
|
||||
/// match, or split on the colon, the console's hide button would 404 against an id the host itself
|
||||
/// produced. Asserting "not 404" is the whole point, so the body is deliberately INVALID — that
|
||||
/// stops at the JSON layer with a 4xx and never reaches the handler, which would otherwise write
|
||||
/// `library-hidden.json` into the developer's real config dir (the same reason the toggle test
|
||||
/// above only exercises its rejection path).
|
||||
#[tokio::test]
|
||||
async fn hide_route_matches_ids_containing_colons() {
|
||||
let app = test_app(test_state(), None);
|
||||
let put = |id: &str| {
|
||||
axum::http::Request::put(format!("/api/v1/library/hidden/{id}"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
// Not a `HiddenToggle` — rejected before the handler runs.
|
||||
.body(Body::from(serde_json::json!({"nope": 1}).to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
for id in ["steam:70", "custom:abc", "heroic:legendary:fc0b13b7"] {
|
||||
let (s, json) = send(&app, put(id)).await;
|
||||
assert_ne!(
|
||||
s,
|
||||
StatusCode::NOT_FOUND,
|
||||
"`{id}` must ROUTE — a colon is a legal path character and every library id has one: {json}"
|
||||
);
|
||||
assert!(
|
||||
s.is_client_error(),
|
||||
"a body that is not a HiddenToggle must be refused, not accepted: {s} {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ library providers
|
||||
|
||||
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user