Compare commits
81
Commits
+6
-3
@@ -1,9 +1,12 @@
|
||||
# Root build context is used only by web/Dockerfile, which needs web/ and
|
||||
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
|
||||
# out of the context upload.
|
||||
# The root build context is used by web/Dockerfile (which needs web/ and
|
||||
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
|
||||
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
|
||||
# context upload.
|
||||
*
|
||||
!web
|
||||
!api/openapi.json
|
||||
!rust-toolchain.toml
|
||||
!ci/pf-host-cc
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -61,6 +61,43 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
|
||||
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
|
||||
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
|
||||
# (E0061 x10) without any job noticing.
|
||||
#
|
||||
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
|
||||
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
|
||||
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
|
||||
# can only type-check these tests via clippy — see the note there.)
|
||||
#
|
||||
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
|
||||
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
|
||||
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
|
||||
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
|
||||
# re-run its entire test suite a second time for no extra coverage.
|
||||
#
|
||||
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
|
||||
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
|
||||
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
|
||||
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
|
||||
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
|
||||
# that combination is what deserves the lint.
|
||||
- name: Clippy + test the feature-gated Linux encode backends
|
||||
run: |
|
||||
cargo clippy -p pf-encode --all-targets --locked \
|
||||
--features nvenc,vulkan-encode,pyrowave -- -D warnings
|
||||
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
|
||||
|
||||
- name: C ABI harness (standalone link proof)
|
||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||
|
||||
@@ -71,6 +108,53 @@ jobs:
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
|
||||
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
|
||||
# this exists so a portability defect fails here instead of surfacing in a release build or
|
||||
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
|
||||
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
|
||||
# compiled cleanly on every target CI built at the time.
|
||||
#
|
||||
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
|
||||
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
|
||||
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
|
||||
rust-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Clippy for aarch64 (deny warnings)
|
||||
run: |
|
||||
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
|
||||
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
|
||||
-p punktfunk-client-session -p punktfunk-client-linux \
|
||||
-- -D warnings
|
||||
|
||||
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
|
||||
# it has to keep compiling on its own, not just as a subset of the default features.
|
||||
- name: Build the session binary, minimal features
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||
-p punktfunk-client-session --no-default-features
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
# build-publish-client-arm64
|
||||
# — the same client package for arm64, CROSS-compiled on the same amd64
|
||||
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
|
||||
# host's encode stack is x86 (NVENC/QSV/AMF).
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
@@ -269,3 +273,94 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
|
||||
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
|
||||
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
|
||||
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
|
||||
build-publish-client-arm64:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
|
||||
# commit), so the arm64 package always shares the amd64 version line.
|
||||
- name: Version + channel
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "package version $V -> apt distribution '$DIST' (arm64)"
|
||||
|
||||
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
|
||||
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
|
||||
- name: dpkg-dev
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||
# and must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build the arm64 client .deb
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
|
||||
bash packaging/debian/build-client-deb.sh
|
||||
# Fail here rather than shipping an amd64 binary under an arm64 package name.
|
||||
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
||||
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
||||
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
||||
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs
|
||||
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
@@ -80,6 +81,41 @@ jobs:
|
||||
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
|
||||
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
|
||||
# install the target against the toolchain the workspace actually pins.
|
||||
build-push-arm64cross:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
IMAGE: punktfunk-rust-ci-arm64cross
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login "$REGISTRY" -u enricobuehler --password-stdin
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
EXTRA=""
|
||||
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
|
||||
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
|
||||
$EXTRA \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
|
||||
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
|
||||
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
|
||||
|
||||
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
|
||||
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
|
||||
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
|
||||
|
||||
@@ -50,6 +50,23 @@ on:
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- 'crates/libvpl-sys/**'
|
||||
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
|
||||
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
|
||||
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
|
||||
# Windows host through one of THEM reached main with no Windows build at all — the same
|
||||
# Cargo.lock-luck gap the two lines above were added to close.
|
||||
- 'crates/pf-frame/**'
|
||||
- 'crates/pf-gpu/**'
|
||||
- 'crates/pf-zerocopy/**'
|
||||
- 'crates/pf-host-config/**'
|
||||
- 'crates/pf-capture/**'
|
||||
- 'crates/pf-win-display/**'
|
||||
- 'crates/pf-vdisplay/**'
|
||||
- 'crates/pf-inject/**'
|
||||
- 'crates/pf-paths/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'crates/pf-clipboard/**'
|
||||
- 'crates/pyrowave-sys/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -154,8 +171,23 @@ jobs:
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
||||
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
|
||||
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
|
||||
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
||||
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
||||
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
||||
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
||||
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
||||
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
||||
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
||||
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
||||
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
||||
# worth it while ci.yml executes the same tests.
|
||||
run: |
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
|
||||
Generated
+30
-27
@@ -2194,7 +2194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2334,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2823,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2844,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2868,7 +2868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2886,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2966,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2981,6 +2981,7 @@ dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
@@ -2998,14 +2999,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3020,10 +3021,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3050,7 +3052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3062,7 +3064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,6 +3073,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3269,7 +3272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3285,7 +3288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3301,7 +3304,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3316,7 +3319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3335,7 +3338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3367,7 +3370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3451,7 +3454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3465,7 +3468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3488,7 +3491,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.19.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+3
-3
@@ -5106,7 +5106,7 @@
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
@@ -5150,7 +5150,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
"description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5160,7 +5160,7 @@
|
||||
},
|
||||
"video_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the video stream thread is running."
|
||||
"description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
|
||||
#
|
||||
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
|
||||
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
|
||||
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
|
||||
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
|
||||
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
|
||||
# types it has never heard of.
|
||||
#
|
||||
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
|
||||
# directory already on its system include path (keeping it in the original, later position),
|
||||
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
|
||||
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
|
||||
# libav*-dev headers are installed and on the default search path, at the same version (both
|
||||
# come from this Ubuntu release).
|
||||
#
|
||||
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
|
||||
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
|
||||
set -euo pipefail
|
||||
|
||||
declare -a out=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
|
||||
-I)
|
||||
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
out+=("$1" "${2-}")
|
||||
shift 2
|
||||
;;
|
||||
# `-Idir` glued into one argument.
|
||||
-I*aarch64-linux-gnu*)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
out+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec /usr/bin/cc "${out[@]}"
|
||||
@@ -0,0 +1,81 @@
|
||||
# Cross-compiling CI builder: amd64 host toolchain + an arm64 multiarch sysroot, for the
|
||||
# aarch64 Linux CLIENT artifacts (punktfunk-client + punktfunk-session).
|
||||
#
|
||||
# docker build -f ci/rust-ci-arm64cross.Dockerfile -t punktfunk-rust-ci-arm64cross .
|
||||
#
|
||||
# Derived from punktfunk-rust-ci so the Rust toolchain, clang, and CMake are byte-identical
|
||||
# to the amd64 legs — this image only adds the target side. Kept as a SEPARATE image rather
|
||||
# than folded into the base because the :arm64 dev libs are ~1 GB that every other CI job
|
||||
# would otherwise pull for nothing.
|
||||
#
|
||||
# Client only: the Linux HOST stays amd64 (its encode stack is NVENC/QSV/AMF), so none of the
|
||||
# host's CUDA/GBM link deps are mirrored here.
|
||||
#
|
||||
# Ubuntu splits archives by architecture: amd64 lives on archive.ubuntu.com, every port
|
||||
# (arm64 included) on ports.ubuntu.com. Both stanzas therefore have to be pinned with an
|
||||
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
|
||||
#
|
||||
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
|
||||
FROM git.unom.io/unom/punktfunk-rust-ci:latest
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 1. Pin the stock sources to amd64, add ports.ubuntu.com for arm64.
|
||||
RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& . /etc/os-release \
|
||||
&& printf 'Types: deb\nArchitectures: arm64\nURIs: http://ports.ubuntu.com/ubuntu-ports/\nSuites: %s %s-updates %s-backports %s-security\nComponents: main universe restricted multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
|
||||
"$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" \
|
||||
> /etc/apt/sources.list.d/ubuntu-ports-arm64.sources \
|
||||
&& dpkg --add-architecture arm64
|
||||
|
||||
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
|
||||
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
|
||||
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
crossbuild-essential-arm64 \
|
||||
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
|
||||
libavfilter-dev:arm64 libavdevice-dev:arm64 \
|
||||
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
|
||||
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
|
||||
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
|
||||
# default. The base image bakes whatever `stable` was at its build time, while every build
|
||||
# in the repo switches to the exact channel in rust-toolchain.toml; adding the target to
|
||||
# the default toolchain instead leaves the pinned one without an aarch64 std, and the build
|
||||
# dies on `can't find crate for core` a few hundred crates in. Running rustup from a
|
||||
# directory that contains the pin file resolves the right toolchain (and pre-downloads it,
|
||||
# which every workspace job would otherwise pay for on first use).
|
||||
COPY rust-toolchain.toml /opt/pf-toolchain/
|
||||
WORKDIR /opt/pf-toolchain
|
||||
RUN rustup target add aarch64-unknown-linux-gnu && rustup show
|
||||
WORKDIR /
|
||||
|
||||
# 4. Cross wiring. Everything in this image is a cross build, so the plain (un-suffixed)
|
||||
# variables are safe and cover the crates that roll their own pkg-config/bindgen calls
|
||||
# instead of going through the target-scoped lookups.
|
||||
# * PKG_CONFIG uses Debian's multiarch wrapper, which resolves the arm64 .pc files and
|
||||
# rewrites -I/-L into the sysroot without per-crate cooperation.
|
||||
# * BINDGEN_EXTRA_CLANG_ARGS: clang defaults to the host triple, so bindgen would parse
|
||||
# arm64 headers with amd64 type layouts (silently wrong, not a build error) — the
|
||||
# explicit --target plus the multiarch include dir is what keeps the layouts honest.
|
||||
# * CC_x86_64_unknown_linux_gnu routes HOST-targeted compiles through a wrapper that
|
||||
# strips the arm64 include dirs — see ci/pf-host-cc for the ffmpeg-sys-next probe it
|
||||
# exists for.
|
||||
COPY ci/pf-host-cc /usr/local/bin/pf-host-cc
|
||||
RUN chmod 0755 /usr/local/bin/pf-host-cc
|
||||
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||
CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc \
|
||||
PKG_CONFIG=aarch64-linux-gnu-pkg-config \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-unknown-linux-gnu -I/usr/include/aarch64-linux-gnu"
|
||||
|
||||
# Fail the BUILD, not some later CI job, if the wrapper or a sysroot .pc is missing.
|
||||
RUN command -v aarch64-linux-gnu-pkg-config \
|
||||
&& aarch64-linux-gnu-pkg-config --cflags libavcodec sdl3 gtk4 libpipewire-0.3 \
|
||||
&& aarch64-linux-gnu-gcc -dumpmachine | grep -q aarch64
|
||||
@@ -50,6 +50,9 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
@@ -384,6 +387,26 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
|
||||
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||
// suspend a process for going to background, so without this the native worker kept running and
|
||||
// its QUIC connection kept answering the host's keep-alives — the user was long gone but the
|
||||
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||
//
|
||||
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||
DisposableEffect(handle) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
onDispose { lifecycle?.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
||||
// Delayed a beat: the grab needs window focus and the capture view attached.
|
||||
LaunchedEffect(handle) {
|
||||
|
||||
@@ -144,14 +144,26 @@ struct ContentView: View {
|
||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||
// parallel session — this drives the one `model` ContentView owns.
|
||||
.onOpenURL { handleDeepLink($0) }
|
||||
#if os(iOS)
|
||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
//
|
||||
// Backgrounding MUST end the session one way or the other: the app keeps running while
|
||||
// streaming (the `audio` background mode plus a live audio session), so its QUIC connection
|
||||
// keeps answering the host's keep-alives with the user long gone — the host has no way to
|
||||
// tell that apart from someone watching, and the session survived indefinitely. Either hold
|
||||
// it under the opt-in keep-alive (bounded by that path's own auto-disconnect timer) or end
|
||||
// it here.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background:
|
||||
if backgroundKeepAlive, model.phase == .streaming {
|
||||
guard model.phase == .streaming else { break }
|
||||
if backgroundKeepAlive {
|
||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||
} else {
|
||||
// Not deliberate: the user may come straight back, so let the host linger the
|
||||
// display for a fast reconnect instead of tearing it down.
|
||||
model.disconnect(deliberate: false)
|
||||
}
|
||||
case .active:
|
||||
model.exitBackground()
|
||||
@@ -159,7 +171,11 @@ struct ContentView: View {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Live Activity lifecycle, driven from the model's published state.
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Live Activity lifecycle, driven from the model's published state. iPhone/iPad only —
|
||||
// ActivityKit (and so `liveActivity`) does not exist on tvOS, which is why this stays in its
|
||||
// own os(iOS) block rather than riding the backgrounding driver's.
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Keeps the local display awake for the duration of a streaming session.
|
||||
//
|
||||
// A stream is not "user activity" to the OS: the pixels arrive over the network and the input that
|
||||
// drives them is often a game controller, which does NOT feed the HID idle timer on any Apple
|
||||
// platform. So a controller-only session reliably idles the panel out from under the user — the
|
||||
// same reason the Android client holds FLAG_KEEP_SCREEN_ON while streaming (StreamScreen.kt).
|
||||
//
|
||||
// Held by SessionModel from `beginStreaming` to `disconnect`, so it is scoped to the session and
|
||||
// never leaks past it (including a host-ended or timed-out background session, which both land in
|
||||
// `disconnect`).
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import IOKit.pwr_mgt
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class DisplaySleepGuard {
|
||||
#if os(macOS)
|
||||
/// The `beginActivity` token; non-nil exactly while held.
|
||||
private var activity: NSObjectProtocol?
|
||||
/// Re-used across heartbeats so the whole session shares one assertion instead of
|
||||
/// accumulating one per tick.
|
||||
private var userActivityAssertion: IOPMAssertionID = IOPMAssertionID(0)
|
||||
private var heartbeat: Timer?
|
||||
|
||||
/// The power assertion defers DISPLAY SLEEP but not the screen saver — that runs off the
|
||||
/// HID idle timer, which a controller-only session never touches. Declaring user activity
|
||||
/// on an interval well under the shortest selectable screen-saver delay (1 minute) keeps
|
||||
/// that timer from ever reaching it. Side effect, and the intended one: an idle-lock
|
||||
/// configured to follow the screen saver is deferred too, for the session only.
|
||||
private static let heartbeatInterval: TimeInterval = 30
|
||||
#endif
|
||||
|
||||
private(set) var isHeld = false
|
||||
|
||||
/// Idempotent — a second acquire while held is a no-op.
|
||||
func acquire() {
|
||||
guard !isHeld else { return }
|
||||
isHeld = true
|
||||
#if os(macOS)
|
||||
// The high-level Foundation API over IOKit power assertions: `.idleDisplaySleepDisabled`
|
||||
// is the panel, `.userInitiated` also holds off idle SYSTEM sleep and sudden termination
|
||||
// for a session the user is watching in real time.
|
||||
activity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .idleDisplaySleepDisabled],
|
||||
reason: "Punktfunk streaming session")
|
||||
declareUserActivity()
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: Self.heartbeatInterval, repeats: true) {
|
||||
[weak self] _ in
|
||||
MainActor.assumeIsolated { self?.declareUserActivity() }
|
||||
}
|
||||
// The stream runs under a tracking run-loop mode while a menu or a window resize is up;
|
||||
// .common keeps the heartbeat ticking through those.
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
heartbeat = timer
|
||||
#else
|
||||
// iOS/iPadOS/tvOS: app-wide, and ignored while backgrounded — the background keep-alive
|
||||
// (audio-only, video dropped) correctly lets the device sleep without touching this.
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Idempotent — safe to call when not held (`disconnect` runs on paths that never streamed).
|
||||
func release() {
|
||||
guard isHeld else { return }
|
||||
isHeld = false
|
||||
#if os(macOS)
|
||||
heartbeat?.invalidate()
|
||||
heartbeat = nil
|
||||
if let activity {
|
||||
ProcessInfo.processInfo.endActivity(activity)
|
||||
self.activity = nil
|
||||
}
|
||||
if userActivityAssertion != IOPMAssertionID(0) {
|
||||
IOPMAssertionRelease(userActivityAssertion)
|
||||
userActivityAssertion = IOPMAssertionID(0)
|
||||
}
|
||||
#else
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Resets the HID idle timer (see `heartbeatInterval`). `kIOPMUserActiveLocal` = activity at
|
||||
/// this Mac's own display, which is what a stream being watched here is.
|
||||
private func declareUserActivity() {
|
||||
IOPMAssertionDeclareUserActivity(
|
||||
"Punktfunk streaming session" as CFString,
|
||||
kIOPMUserActiveLocal,
|
||||
&userActivityAssertion)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -196,6 +196,11 @@ final class SessionModel: ObservableObject {
|
||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||||
private var backgroundTimer: DispatchSourceTimer?
|
||||
|
||||
/// Holds off display sleep (and, on macOS, the screen saver) for the life of a session —
|
||||
/// nothing about watching a stream looks like user activity to the OS, least of all a
|
||||
/// controller-only session. Acquired in `beginStreaming`, released in `disconnect`.
|
||||
private let displaySleepGuard = DisplaySleepGuard()
|
||||
|
||||
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
|
||||
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
|
||||
/// — `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
|
||||
@@ -455,6 +460,8 @@ final class SessionModel: ObservableObject {
|
||||
func disconnect(deliberate: Bool = true) {
|
||||
statsTimer?.invalidate()
|
||||
statsTimer = nil
|
||||
// No-op when this session never reached `.streaming` (a refused/aborted connect).
|
||||
displaySleepGuard.release()
|
||||
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
@@ -550,6 +557,7 @@ final class SessionModel: ObservableObject {
|
||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||
// flip this phase change causes, released/re-engaged by the user from there).
|
||||
phase = .streaming
|
||||
displaySleepGuard.acquire()
|
||||
// Audio starts with streaming, not during the trust prompt — no host sound (or
|
||||
// mic uplink!) before the user trusted the host. Devices come from Settings;
|
||||
// "" = system default.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! scenes.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
@@ -252,18 +252,6 @@ fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop an fp-less placeholder for `addr:port` (see `headless_pair`). No-op when none exists.
|
||||
fn forget_placeholder(addr: &str, port: u16) {
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known
|
||||
.hosts
|
||||
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
||||
if known.hosts.len() != before {
|
||||
let _ = known.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
||||
|
||||
@@ -8,6 +8,7 @@ presenter of the Linux client re-architecture (punktfunk-planning:
|
||||
```
|
||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
||||
```
|
||||
|
||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||
@@ -17,9 +18,16 @@ pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<fil
|
||||
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
||||
|
||||
Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never
|
||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
||||
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
||||
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
||||
label the host files this client under, defaulting to the hostname. It is in the
|
||||
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
||||
to pull in Skia.
|
||||
|
||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
};
|
||||
|
||||
let opts = ConsoleOptions {
|
||||
device_name: device_name(),
|
||||
device_name: trust::device_name(),
|
||||
deck: is_steam_deck(),
|
||||
};
|
||||
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
||||
@@ -251,22 +251,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's name — what the host lists this client as after pairing.
|
||||
fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
fn host_display_name(name: &str, addr: &str) -> String {
|
||||
if name.trim().is_empty() {
|
||||
addr.to_string()
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
|
||||
//! via the shell's headless `--pair`) makes this binary connect silently.
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||
//! with no window and no toolkit, for machines that have only a shell.
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
@@ -61,6 +62,54 @@ mod session_main {
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||
/// later stream connect silently.
|
||||
///
|
||||
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||
fn headless_pair(pin: &str) -> u8 {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||
EXIT_TRUST_REJECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]`, port defaulting to the native 9777.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
match target.rsplit_once(':') {
|
||||
@@ -312,6 +361,13 @@ mod session_main {
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. Sits with the other
|
||||
// non-streaming subcommands, above every graphics call — the box doing this may have
|
||||
// no display at all.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
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.
|
||||
@@ -369,12 +425,14 @@ mod session_main {
|
||||
eprintln!(
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
||||
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||
\n\
|
||||
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||
library. --connect never dials a host it has no pinned fingerprint for —\n\
|
||||
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
|
||||
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||
client."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
@@ -406,7 +464,7 @@ mod session_main {
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
|
||||
@@ -2191,7 +2191,15 @@ mod pipewire {
|
||||
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||
// dmabuf straight to the encoder.
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
||||
//
|
||||
// ...unless the encoder already proved it cannot import them here. A driver that refuses
|
||||
// the compositor's buffers refuses them identically on every retry, so without this the
|
||||
// session died on its first frame and every reconnect repeated it. The latch (set by the
|
||||
// encode side after consecutive import failures) is what turns that into one bad session
|
||||
// followed by a working, if slower, host.
|
||||
let raw_dmabuf_off = raw_passthrough && pf_zerocopy::raw_dmabuf_import_disabled();
|
||||
let vaapi_passthrough =
|
||||
zerocopy && !force_shm && importer.is_none() && raw_passthrough && !raw_dmabuf_off;
|
||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
||||
@@ -2249,6 +2257,12 @@ mod pipewire {
|
||||
tracing::info!(
|
||||
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
|
||||
);
|
||||
} else if raw_dmabuf_off {
|
||||
tracing::warn!(
|
||||
"zero-copy raw-dmabuf passthrough disabled after repeated encoder import failures \
|
||||
— capturing CPU frames instead (this host's GPU driver would not import the \
|
||||
compositor's buffers)"
|
||||
);
|
||||
} else if zerocopy && !want_dmabuf {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
@@ -2652,6 +2666,18 @@ mod pipewire {
|
||||
build_default_format_obj(preferred)
|
||||
};
|
||||
|
||||
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
|
||||
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
|
||||
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
|
||||
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
|
||||
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
|
||||
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
|
||||
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
|
||||
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
|
||||
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
|
||||
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
|
||||
// version and are composited host-side instead (see `xfixes_cursor.rs`).
|
||||
//
|
||||
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
|
||||
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
||||
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
||||
|
||||
@@ -10,20 +10,31 @@
|
||||
//!
|
||||
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
||||
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
||||
//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick
|
||||
//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so
|
||||
//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own
|
||||
//! current cursor. This is why a single-display read froze the pointer the moment a game on the
|
||||
//! OTHER Xwayland took focus.
|
||||
//! inactive display's pointer is frozen. So the source connects to ALL of them and publishes from
|
||||
//! the one gamescope is actually drawing the pointer on; it reads that display's shape too, since
|
||||
//! each Xwayland has its own current cursor. This is why a single-display read froze the pointer
|
||||
//! the moment a game on the OTHER Xwayland took focus.
|
||||
//!
|
||||
//! Two X sources per display, split by cost (Sunshine's split):
|
||||
//! Three X sources per display, split by cost (Sunshine's split, plus gamescope's own verdict):
|
||||
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
||||
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also
|
||||
//! doubles as the focus signal (the display whose pointer moves is the active one).
|
||||
//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes
|
||||
//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the
|
||||
//! image comes back fully transparent → `visible: false`, which the encode loop strips before
|
||||
//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope).
|
||||
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth.
|
||||
//! * **Shape / hotspot** — `XFixesGetCursorImage`, refreshed only after an XFixes `CursorNotify`
|
||||
//! (a real cursor change). A fully-transparent image reads as hidden.
|
||||
//! * **Visibility + focus** — [`GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`](GS_CURSOR_FEEDBACK) on the
|
||||
//! root, read at connect and re-read on its `PropertyNotify`.
|
||||
//!
|
||||
//! **Why the feedback atom and not pointer motion.** gamescope hides its pointer by WARPING the X
|
||||
//! pointer to the root's bottom-right corner pixel — it does NOT swap in a transparent X cursor, so
|
||||
//! `XFixesGetCursorImage` keeps handing back the last opaque arrow. The original "follow whichever
|
||||
//! display's pointer moved" heuristic therefore stuck to the parked display (a parked pointer never
|
||||
//! moves again) and composited that arrow at `(w-1, h-1)`: a sliver of cursor welded to the corner
|
||||
//! of the stream for the rest of the session, while the real pointer went undrawn. Reported
|
||||
//! on-glass as "part of cursor shows up on bottom right … isn't where it really is", in every game
|
||||
//! (a game grabs the pointer, so the hide is permanent). Measured on a live 1920x1080 Gaming Mode
|
||||
//! session: real motion ⇒ pointer live + feedback `1`; 3 s idle (`--hide-cursor-delay 3000`) ⇒
|
||||
//! pointer `(1919, 1079)` + feedback `0`. The atom answers BOTH questions correctly for a static
|
||||
//! pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all — so
|
||||
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -35,7 +46,11 @@ use pf_frame::CursorOverlay;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::errors::ReplyError;
|
||||
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
||||
use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window};
|
||||
use x11rb::protocol::xproto::{
|
||||
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply,
|
||||
Window,
|
||||
};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||
@@ -47,6 +62,17 @@ static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// and must out-run a 240 fps session or the pointer stutters.
|
||||
const POLL: Duration = Duration::from_millis(4);
|
||||
|
||||
/// gamescope's own pointer verdict, published on EVERY nested Xwayland's root: `1` on the server
|
||||
/// whose pointer gamescope is currently drawing, `0` on the others — and `0` on all of them once
|
||||
/// the pointer is hidden (a game grabbed it, or `--hide-cursor-delay` fired). See the module docs
|
||||
/// for why this, and not pointer motion, is the signal this source follows.
|
||||
const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
||||
|
||||
/// Self-heal cadence for the feedback re-read: `PropertyNotify` drives it, this only covers a
|
||||
/// missed/coalesced event (and a gamescope that publishes the atom after we connected). One
|
||||
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||
pub(super) struct XFixesCursorSource {
|
||||
@@ -68,7 +94,9 @@ impl XFixesCursorSource {
|
||||
let mut displays = Vec::new();
|
||||
for (dpy, xauth) in targets {
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)),
|
||||
Ok((conn, root, feedback)) => {
|
||||
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
dpy = %dpy,
|
||||
error = %e,
|
||||
@@ -84,9 +112,13 @@ impl XFixesCursorSource {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||
tracing::info!(
|
||||
displays = ?names,
|
||||
"gamescope cursor: XFixes source live — following the focused Xwayland's pointer"
|
||||
cursor_feedback = feedback,
|
||||
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
|
||||
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||
);
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -111,10 +143,15 @@ impl Drop for XFixesCursorSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the
|
||||
/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only,
|
||||
/// so set it under the lock (the host isn't a gamescope child), connect, then restore.
|
||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> {
|
||||
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||
/// returning the connection, root window and this display's initial
|
||||
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
||||
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
||||
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
||||
/// then restore.
|
||||
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
||||
|
||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
let (conn, screen_num) = {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
@@ -148,8 +185,41 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Windo
|
||||
.map_err(ReplyError::from)
|
||||
.and_then(|c| c.check())
|
||||
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
||||
|
||||
// …and whenever gamescope republishes its cursor verdict. Interned with `only_if_exists=false`
|
||||
// so we hold a matchable atom id even on a gamescope that sets the property later; a failure to
|
||||
// select PROPERTY_CHANGE is NOT fatal — the resync re-read still tracks it, just at 250 ms.
|
||||
let feedback_atom = conn
|
||||
.intern_atom(false, GS_CURSOR_FEEDBACK.as_bytes())
|
||||
.map_err(ReplyError::from)
|
||||
.and_then(|c| c.reply())
|
||||
.map(|r| r.atom)
|
||||
.unwrap_or(0);
|
||||
if let Ok(c) = conn.change_window_attributes(
|
||||
root,
|
||||
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
||||
) {
|
||||
let _ = c.check();
|
||||
}
|
||||
let _ = conn.flush();
|
||||
Ok((conn, root))
|
||||
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||
Ok((conn, root, (feedback_atom, feedback)))
|
||||
}
|
||||
|
||||
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||
/// unreadable (not a gamescope that publishes it) — the caller then falls back to the pointer-motion
|
||||
/// heuristic rather than blanking the cursor, so an older gamescope keeps today's behaviour.
|
||||
fn read_cursor_feedback(conn: &RustConnection, root: Window, atom: Atom) -> Option<bool> {
|
||||
if atom == 0 {
|
||||
return None;
|
||||
}
|
||||
let reply = conn
|
||||
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?;
|
||||
let value = reply.value32()?.next()?;
|
||||
Some(value != 0)
|
||||
}
|
||||
|
||||
/// One gamescope Xwayland the source tracks.
|
||||
@@ -163,12 +233,22 @@ struct XDisplay {
|
||||
shape: Shape,
|
||||
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
||||
need_shape: bool,
|
||||
/// Interned [`GS_CURSOR_FEEDBACK`] atom (`0` = intern failed — treated as absent).
|
||||
feedback_atom: Atom,
|
||||
/// gamescope's verdict for THIS display: `Some(true)` = it is drawing the pointer here,
|
||||
/// `Some(false)` = it is not, `None` = this gamescope publishes no verdict at all.
|
||||
gs_visible: Option<bool>,
|
||||
/// The X connection died (game/Xwayland exited) — skip it.
|
||||
dead: bool,
|
||||
}
|
||||
|
||||
impl XDisplay {
|
||||
fn new(name: String, conn: RustConnection, root: Window) -> Self {
|
||||
fn new(
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||
) -> Self {
|
||||
XDisplay {
|
||||
name,
|
||||
conn,
|
||||
@@ -176,9 +256,20 @@ impl XDisplay {
|
||||
last_pos: None,
|
||||
shape: Shape::default(),
|
||||
need_shape: true,
|
||||
feedback_atom,
|
||||
gs_visible,
|
||||
dead: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read gamescope's verdict, keeping a previously-seen one if the read fails (a transient
|
||||
/// failure must not look like "this gamescope has no feedback" and re-arm the motion heuristic).
|
||||
fn resync_feedback(&mut self) {
|
||||
let fresh = read_cursor_feedback(&self.conn, self.root, self.feedback_atom);
|
||||
if fresh.is_some() || self.gs_visible.is_none() {
|
||||
self.gs_visible = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached cursor shape for one display.
|
||||
@@ -209,20 +300,35 @@ fn run(
|
||||
let mut out_serial = 0u64;
|
||||
let mut last_key = (usize::MAX, u64::MAX);
|
||||
let mut warned_image = false;
|
||||
let mut last_resync = std::time::Instant::now();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// 1) Poll every display's pointer; note which moved since last tick (the focus signal).
|
||||
// A missed/coalesced PropertyNotify would otherwise strand the verdict — re-read on a slow
|
||||
// cadence so the source always converges (also picks the atom up if gamescope adds it late).
|
||||
let resync = last_resync.elapsed() >= FEEDBACK_RESYNC;
|
||||
if resync {
|
||||
last_resync = std::time::Instant::now();
|
||||
}
|
||||
|
||||
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||
// signal, used only when this gamescope publishes no cursor verdict).
|
||||
let mut active_moved = false;
|
||||
let mut other_moved: Option<usize> = None;
|
||||
for (i, d) in displays.iter_mut().enumerate() {
|
||||
if d.dead {
|
||||
continue;
|
||||
}
|
||||
// Drain pending events; the only ones selected are CursorNotify, so ANY event means
|
||||
// "re-read this display's shape". poll_for_event never blocks.
|
||||
// Drain pending events. Two kinds are selected: XFixes CursorNotify (the shape
|
||||
// changed) and root PropertyNotify (gamescope republished its cursor verdict, among
|
||||
// the many other properties it keeps on the root — hence the atom match).
|
||||
let mut need_feedback = resync;
|
||||
loop {
|
||||
match d.conn.poll_for_event() {
|
||||
Ok(Some(_)) => d.need_shape = true,
|
||||
Ok(Some(Event::XfixesCursorNotify(_))) => d.need_shape = true,
|
||||
Ok(Some(Event::PropertyNotify(ev))) => {
|
||||
need_feedback |= d.feedback_atom != 0 && ev.atom == d.feedback_atom;
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
d.dead = true;
|
||||
@@ -230,6 +336,9 @@ fn run(
|
||||
}
|
||||
}
|
||||
}
|
||||
if need_feedback && !d.dead {
|
||||
d.resync_feedback();
|
||||
}
|
||||
match fetch_pointer(&d.conn, d.root) {
|
||||
Ok(p) if p.same_screen => {
|
||||
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
||||
@@ -248,13 +357,11 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise
|
||||
// follow another display that moved. If the active one died, fall to any live display.
|
||||
if !active_moved {
|
||||
if let Some(j) = other_moved {
|
||||
active = j;
|
||||
}
|
||||
}
|
||||
// 2) Pick the display to publish from, and decide whether a pointer should be drawn at all.
|
||||
let states: Vec<(bool, Option<bool>)> =
|
||||
displays.iter().map(|d| (d.dead, d.gs_visible)).collect();
|
||||
let hidden_by_gamescope;
|
||||
(active, hidden_by_gamescope) = pick_active(&states, active, active_moved, other_moved);
|
||||
if displays.get(active).is_none_or(|d| d.dead) {
|
||||
match displays.iter().position(|d| !d.dead) {
|
||||
Some(k) => active = k,
|
||||
@@ -283,7 +390,11 @@ fn run(
|
||||
|
||||
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
||||
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
||||
// A pointer gamescope is not drawing is published `visible: false`, NOT dropped: the
|
||||
// encode loop overwrites the frame's overlay from this slot and strips invisible ones, so
|
||||
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||
let d = &displays[active];
|
||||
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||
(Some((px, py)), false) => {
|
||||
let key = (active, d.shape.serial);
|
||||
@@ -301,7 +412,7 @@ fn run(
|
||||
serial: out_serial,
|
||||
hot_x: d.shape.hot_x,
|
||||
hot_y: d.shape.hot_y,
|
||||
visible: d.shape.visible,
|
||||
visible: drawn,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
@@ -314,6 +425,39 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which display to publish from, and whether gamescope is drawing NO pointer right now —
|
||||
/// `(active, hidden)`. `states` is one `(dead, gs_visible)` per display, in `displays` order.
|
||||
///
|
||||
/// PREFERRED: gamescope's own verdict. It is authoritative for a STATIC pointer, which is exactly
|
||||
/// the case motion cannot read — a game grabs the pointer, gamescope parks it in the bottom-right
|
||||
/// corner, and "follow whichever moved" then never switches again (see the module docs).
|
||||
///
|
||||
/// FALLBACK, only when NO live display publishes the verdict: the original motion heuristic —
|
||||
/// sticky to the active display while its pointer moves (no flapping), else follow another that
|
||||
/// moved. `hidden` is never asserted on this path, so an older gamescope keeps today's behaviour.
|
||||
fn pick_active(
|
||||
states: &[(bool, Option<bool>)],
|
||||
active: usize,
|
||||
active_moved: bool,
|
||||
other_moved: Option<usize>,
|
||||
) -> (usize, bool) {
|
||||
let live = |&(dead, _): &(bool, Option<bool>)| !dead;
|
||||
if states.iter().filter(|s| live(s)).any(|(_, v)| v.is_some()) {
|
||||
return match states.iter().position(|s| live(s) && s.1 == Some(true)) {
|
||||
// gamescope is drawing the pointer here — publish from it.
|
||||
Some(i) => (i, false),
|
||||
// Drawing none of them (a game grabbed it, or the idle auto-hide fired). Keep `active`
|
||||
// so the shape cache and last position stay warm for the re-show; the caller publishes
|
||||
// the overlay `visible: false` instead of dropping it.
|
||||
None => (active, true),
|
||||
};
|
||||
}
|
||||
match other_moved {
|
||||
Some(j) if !active_moved => (j, false),
|
||||
_ => (active, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
||||
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
||||
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
||||
@@ -364,3 +508,60 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pick_active;
|
||||
|
||||
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||
const BPM: usize = 0;
|
||||
const GAME: usize = 1;
|
||||
|
||||
#[test]
|
||||
fn follows_the_display_gamescope_draws_on() {
|
||||
// Verdict beats motion: gamescope says the game's Xwayland owns the pointer, so we publish
|
||||
// from it even though only BPM's (parked) pointer looks like it moved.
|
||||
let states = [(false, Some(false)), (false, Some(true))];
|
||||
assert_eq!(pick_active(&states, BPM, false, Some(BPM)), (GAME, false));
|
||||
}
|
||||
|
||||
/// THE REGRESSION: gamescope hides the pointer by warping it to the root's bottom-right corner
|
||||
/// and leaves the opaque arrow as the X cursor. Nothing moves ever again, so the motion
|
||||
/// heuristic stayed on the parked display and composited that arrow at (w-1, h-1) — a sliver of
|
||||
/// cursor welded to the corner of the stream for the whole session, in every game.
|
||||
#[test]
|
||||
fn a_pointer_gamescope_draws_nowhere_is_hidden_not_parked() {
|
||||
let states = [(false, Some(false)), (false, Some(false))];
|
||||
// `active` is kept (shape cache + last position stay warm for the re-show) but hidden.
|
||||
assert_eq!(pick_active(&states, GAME, false, None), (GAME, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_show_returns_to_the_drawing_display() {
|
||||
let states = [(false, Some(true)), (false, Some(false))];
|
||||
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_displays_verdict_is_ignored() {
|
||||
// The game's Xwayland exited mid-session with a stale `Some(true)`; BPM is the live one.
|
||||
let states = [(false, Some(true)), (true, Some(true))];
|
||||
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||
// …and a dead display's `Some` must not count as "this gamescope publishes a verdict",
|
||||
// which would blank the cursor forever on a gamescope that publishes none.
|
||||
let states = [(false, None), (true, Some(false))];
|
||||
assert_eq!(pick_active(&states, BPM, false, Some(GAME)), (GAME, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_verdict_falls_back_to_the_motion_heuristic() {
|
||||
let none = [(false, None), (false, None)];
|
||||
// Sticky while the active display's own pointer moves…
|
||||
assert_eq!(pick_active(&none, BPM, true, Some(GAME)), (BPM, false));
|
||||
// …otherwise follow the one that moved…
|
||||
assert_eq!(pick_active(&none, BPM, false, Some(GAME)), (GAME, false));
|
||||
// …and never assert `hidden` on this path (that would regress an older gamescope to a
|
||||
// cursorless stream).
|
||||
assert_eq!(pick_active(&none, BPM, false, None), (BPM, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,40 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// This machine's name — the label a host files this client under in its paired-devices list.
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
|
||||
/// build with no GTK to ask), then the usual environment fallbacks.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
|
||||
/// ceremony (`--add-host` with no `--fp`) is stored keyed by address with an empty fingerprint;
|
||||
/// once pairing yields the real one, [`persist_host`] writes a second, fp-keyed entry — so the
|
||||
/// placeholder has to go or the host list shows the same box twice. No-op (and no disk write)
|
||||
/// when there is none, which is the usual case.
|
||||
pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known
|
||||
.hosts
|
||||
.retain(|h| !(h.fp_hex.is_empty() && h.addr == addr && h.port == port));
|
||||
if known.hosts.len() != before {
|
||||
let _ = known.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
//! queue under the device's [`QueueLock`], fence-waited (sub-ms — Phase-0 measured
|
||||
//! 0.067 ms GPU at 1080p on the RTX 5070 Ti).
|
||||
//!
|
||||
//! Output: three separate R8 planes (Y full-res, Cb/Cr half-res) — the decode path
|
||||
//! requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG8 trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! with its planar CSC variant (BT.709 limited — the codec's fixed colour contract,
|
||||
//! there is no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||
//! Output: three separate single-component planes (Y full-res; Cb/Cr half-res, or
|
||||
//! full-res on a 4:4:4 session) — R8, or R16 UNORM on a 10-bit session — the decode
|
||||
//! path requires STORAGE usage and IDENTITY/R swizzles, so the encoder's two-component
|
||||
//! RG trick is not allowed here (pyrowave.h validation). The presenter samples them
|
||||
//! with its planar CSC variant (colour per the negotiated `ColorInfo` — the wavelet
|
||||
//! bitstream carries no VUI). A small ring of plane-sets keeps a decode from overwriting the set
|
||||
//! the presenter is still sampling; the synchronous fence bounds decode-side reuse and
|
||||
//! the ring depth covers present-side latency (≤ 1–2 frames in this pipeline).
|
||||
//!
|
||||
@@ -222,9 +223,10 @@ unsafe extern "C" fn queue_unlock_cb(ud: *mut c_void) {
|
||||
unsafe { (*(ud as *const crate::video::QueueLock)).unlock() }
|
||||
}
|
||||
|
||||
/// One decoded PyroWave frame: three R8 plane images on the presenter's device, GENERAL
|
||||
/// layout, decode-complete (the decoder fence-waits before handing it over). `slot`
|
||||
/// identifies the ring entry; the images/views live as long as the decoder.
|
||||
/// One decoded PyroWave frame: three single-component plane images (R8, or R16 on a
|
||||
/// 10-bit session) on the presenter's device, GENERAL layout, decode-complete (the
|
||||
/// decoder fence-waits before handing it over). `slot` identifies the ring entry; the
|
||||
/// images/views live as long as the decoder.
|
||||
pub struct PyroWavePlanarFrame {
|
||||
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
||||
pub views: [u64; 3],
|
||||
@@ -252,7 +254,8 @@ struct RetiredRing {
|
||||
retired_at: Instant,
|
||||
}
|
||||
|
||||
/// One decode-output plane: R8, storage (decode writes) + sampled (presenter CSC).
|
||||
/// One decode-output plane (`fmt` = R8, or R16 on a 10-bit session): storage (decode
|
||||
/// writes) + sampled (presenter CSC).
|
||||
unsafe fn make_plane(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -539,7 +542,8 @@ impl PyroWaveDecoder {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Plane-set ring: 3 × R8, storage (decode writes) + sampled (presenter CSC).
|
||||
// Plane-set ring: 3 single-component planes (R8; R16 on a 10-bit session),
|
||||
// storage (decode writes) + sampled (presenter CSC).
|
||||
let mem_props = instance.get_physical_device_memory_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
@@ -873,12 +877,26 @@ impl PyroWaveDecoder {
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&pre),
|
||||
);
|
||||
|
||||
// The declared format/extent MUST equal the ring image's real ones: pyrowave wraps
|
||||
// our VkImage under `image_format` and vkCreateImageView's its storage view with
|
||||
// `view_format` (pyrowave_c.cpp `WrappedViewBuffers::wrap` — its own
|
||||
// `pyrowave_image_get_image_view` helper fills both from the image itself).
|
||||
// Declaring R8 over a 10-bit session's R16_UNORM planes is an invalid view the
|
||||
// driver executes anyway: its addressing covers half the surface, so decoded 8-bit
|
||||
// codes fuse pairwise into 16-bit texels (structured garbage) and the never-written
|
||||
// remainder samples as all-plane zeros (saturated green) — the 2026-07 AMD-client
|
||||
// field report. Same discipline for chroma extents: a 4:4:4 ring is full-res.
|
||||
let fmt = if self.hdr16 {
|
||||
pw::VkFormat_VK_FORMAT_R16_UNORM
|
||||
} else {
|
||||
pw::VkFormat_VK_FORMAT_R8_UNORM
|
||||
};
|
||||
let plane = |img: vk::Image, w: u32, h: u32| pw::pyrowave_image_view {
|
||||
image: img.as_raw() as usize as pw::VkImage,
|
||||
width: w,
|
||||
height: h,
|
||||
image_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
view_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
image_format: fmt,
|
||||
view_format: fmt,
|
||||
mip_level: 0,
|
||||
layer: 0,
|
||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
@@ -886,11 +904,16 @@ impl PyroWaveDecoder {
|
||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
||||
};
|
||||
let (w, h) = (self.width, self.height);
|
||||
let (cw, ch) = if self.chroma444 {
|
||||
(w, h)
|
||||
} else {
|
||||
(w / 2, h / 2)
|
||||
};
|
||||
let buffers = pw::pyrowave_gpu_buffers {
|
||||
planes: [
|
||||
plane(self.ring[slot].imgs[0], w, h),
|
||||
plane(self.ring[slot].imgs[1], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[2], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[1], cw, ch),
|
||||
plane(self.ring[slot].imgs[2], cw, ch),
|
||||
],
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
|
||||
@@ -7,6 +7,45 @@
|
||||
use anyhow::Result;
|
||||
use pf_frame::CapturedFrame;
|
||||
|
||||
/// Whether an encoder fed `format` must be built 10-bit — decided by **the pixels that actually
|
||||
/// arrive**, never by the negotiated `bit_depth`.
|
||||
///
|
||||
/// The three Windows backends each derived this as `bit_depth >= 10 || matches!(format, P010 |
|
||||
/// Rgb10a2)`, i.e. the *negotiated* depth could force a 10-bit encoder over an 8-bit capture. That
|
||||
/// combination is not hypothetical: a client advertises 10-bit, the handshake negotiates
|
||||
/// `bit_depth = 10`, and then enabling advanced colour on the IDD virtual display fails — at which
|
||||
/// point the capturer says so and delivers 8-bit NV12 anyway (`pf-capture`'s idd_push logs "10-bit
|
||||
/// HDR was negotiated but enabling advanced color on the virtual display FAILED — encoding 8-bit
|
||||
/// SDR"). Every backend then lost the session, each in its own way: native AMF and native QSV
|
||||
/// `bail!` at open because the format does not match the P010 they derived, and the libavcodec
|
||||
/// path accepted the open and then failed EVERY submit forever (its per-frame depth check
|
||||
/// recomputes from the frame, which never matches), where `reset()` could not help because the
|
||||
/// rebuild re-derived the same wrong depth.
|
||||
///
|
||||
/// Following the pixels keeps the stream alive and, more importantly, keeps it HONEST: the depth
|
||||
/// also selects the colour signalling (BT.2020 PQ vs BT.709) and the staging surface format, so an
|
||||
/// 8-bit capture now yields an 8-bit stream that says it is SDR — which is what the capturer
|
||||
/// already reported it is sending. The negotiated depth remains an upper bound; the session label
|
||||
/// may still claim HDR, and that mismatch belongs to the negotiation, not to the encoder.
|
||||
/// Windows-only: the three backends that derive an encoder depth from a capture live there
|
||||
/// (native AMF, native QSV, libavcodec AMF/QSV). The Linux backends take the depth from the
|
||||
/// negotiated `bit_depth` alone because their capture formats carry it unambiguously.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
|
||||
use pf_frame::PixelFormat;
|
||||
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
if negotiated_depth >= 10 && !ten {
|
||||
tracing::warn!(
|
||||
?format,
|
||||
negotiated_depth,
|
||||
"session negotiated 10-bit but the capturer delivers an 8-bit format — encoding 8-bit \
|
||||
SDR (the stream's colour signalling follows the pixels; check whether advanced colour \
|
||||
failed to enable on the virtual display)"
|
||||
);
|
||||
}
|
||||
ten
|
||||
}
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
@@ -73,7 +112,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
@@ -255,6 +294,19 @@ pub struct EncoderCaps {
|
||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
pub intra_refresh_period: u32,
|
||||
/// The encoder composites [`CapturedFrame::cursor`] into the picture it encodes.
|
||||
///
|
||||
/// `open_video`'s `cursor_blend` argument is a REQUEST, and for most of this crate's life it was
|
||||
/// nothing else: `lib.rs` literally did `let _ = cursor_blend;` and only three backends ever read
|
||||
/// `frame.cursor`. So a session could ask for a composited pointer, get a backend that silently
|
||||
/// discards it, and stream with no mouse cursor at all — the confirmed symptom on the VAAPI
|
||||
/// dmabuf path and the libav-NVENC CUDA path.
|
||||
///
|
||||
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
||||
/// `open_video` can only warn, which it does.
|
||||
pub blends_cursor: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
@@ -313,8 +365,15 @@ pub trait Encoder: Send {
|
||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
||||
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
|
||||
/// `false` (the default impl) = unsupported — the session loop stops asking.
|
||||
///
|
||||
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
|
||||
/// backend restores its sync-retrieve mode — and the latency features that mode carries
|
||||
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
|
||||
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
|
||||
/// the caller polls until it reads `false`. Backends that never escalate return `false`
|
||||
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
|
||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -360,6 +419,16 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
|
||||
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
|
||||
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
|
||||
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
|
||||
/// and the client controller's ack all track what the ASIC really targets (a controller fed
|
||||
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
|
||||
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
@@ -379,6 +448,20 @@ pub trait Encoder: Send {
|
||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
///
|
||||
/// **The two production encode loops deliberately do not call this**, and that is not an
|
||||
/// oversight to be "fixed" by a later sweep. Both reach their exit only after the transport is
|
||||
/// already gone (the client disconnected, or the session was stopped), so the AUs a flush would
|
||||
/// recover have nowhere to go — while flushing is the one call on this trait that can BLOCK on a
|
||||
/// wedged encoder, on precisely the teardown path a stopped session needs to complete promptly.
|
||||
/// The Linux direct-SDK NVENC backend makes that concrete: its retrieve-thread join is untimed
|
||||
/// (see the note in `enc/linux/nvenc_cuda.rs`), so a flush there could hang a session that is
|
||||
/// already ending.
|
||||
///
|
||||
/// It is kept rather than deleted because it does have real consumers: the `spike` dev
|
||||
/// subcommand, which encodes a FINITE clip and genuinely wants the tail, and the `#[ignore]`d
|
||||
/// hardware smoke tests across the backends, which assert the drain contract on real GPUs.
|
||||
/// Those are finite-stream users; a live session is not one.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -413,6 +496,17 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
|
||||
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
|
||||
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
|
||||
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
|
||||
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
|
||||
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
|
||||
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
|
||||
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
|
||||
/// comfortably single-engine) on AUTO.
|
||||
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
@@ -426,6 +520,35 @@ pub(crate) fn vbv_frames_env() -> f64 {
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// The same HRD/VBV window as [`vbv_frames_env`], expressed the way the Vulkan Video encode API
|
||||
/// wants it: `(virtualBufferSizeInMs, initialVirtualBufferSizeInMs)`.
|
||||
///
|
||||
/// Every other backend states the window in **bits** (`bitrate / fps × frames`); Vulkan states it
|
||||
/// in **milliseconds**. `vulkan_video.rs` consumes this ONLY when the driver advertises VBR
|
||||
/// (WP6.3): a tight window under CBR makes the driver stuff underspent frames with filler NALs up
|
||||
/// to the exact rate share — measured 97 % filler on the 780M — because CBR must keep the CPB from
|
||||
/// overflowing and Vulkan exposes no filler-suppression control. VBR permits the underspend, so
|
||||
/// the tight window only ever *bounds* a complex frame.
|
||||
///
|
||||
/// The initial fill stays at half the window, preserving the RATIO the hardcoded (1000, 500)
|
||||
/// pair had — the direct-NVENC house shape uses a FULL-window initial fill instead; measured on
|
||||
/// RADV the difference is inert (the firmware showed no window sensitivity at all). Both
|
||||
/// VUIDs on `VkVideoEncodeRateControlInfoKHR`'s window fields are satisfied by construction: the
|
||||
/// window clamps to `>= 1` so it is non-zero, and `window / 2 <= window` always
|
||||
/// (`VUID-...-08358` is `<=`, relaxed in Vulkan 1.3.299).
|
||||
///
|
||||
/// Carries its only caller's gate: `vulkan_video.rs` is the sole ms-form consumer, and with the
|
||||
/// crate-wide `allow(dead_code)` gone (WP0.3) an item unused in ANY feature combination is a hard
|
||||
/// error — this is dead on every Windows leg.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
pub(crate) fn vbv_window_ms(fps: u32) -> (u32, u32) {
|
||||
let frames = vbv_frames_env();
|
||||
let ms = (frames * 1000.0 / fps.max(1) as f64).round();
|
||||
// `f64 as u32` saturates at the bounds in Rust, so an absurd `PUNKTFUNK_VBV_FRAMES` cannot wrap.
|
||||
let window = (ms as u32).max(1);
|
||||
(window, window / 2)
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
@@ -454,6 +577,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
// PyroWave's vendored rate controller packs the 32×32 block index into the low 16 bits of
|
||||
// `RDOperation::block_offset_saving` (pyrowave-sys `patches/0002-rdo-saving-clamp.patch`).
|
||||
// Past `u16::MAX` blocks the index collides with the `saving` field, the resolve over-credits,
|
||||
// and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into —
|
||||
// whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out.
|
||||
// So this is a hard cap, not a quality knob.
|
||||
//
|
||||
// Checked against 4:2:0, the *most permissive* chroma: a mode that cannot fit even there can
|
||||
// fit no PyroWave session at all, so it belongs at this single chokepoint (which both the
|
||||
// negotiator and `open_video_backend` run) rather than only in the per-backend opens. 4:4:4
|
||||
// has twice the block count and is checked again at open, where the real chroma is known —
|
||||
// and the negotiator's 4:4:4 → 4:2:0 downgrade means an oversized mode arrives at the encoder
|
||||
// as 4:2:0, which is exactly the case the old open-time guard skipped.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
if codec == Codec::PyroWave && !crate::pyrowave_mode_fits_rdo(width, height, false) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: exceeds the rate controller's 16-bit \
|
||||
block index (pyrowave-sys patches/0002) — lower the client resolution"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -461,6 +604,31 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// WP6.3. The window VUIDs on `VkVideoEncodeRateControlInfoKHR` are the whole contract of
|
||||
/// this helper, and both are edge cases: the window must be non-zero (a high-refresh mode
|
||||
/// rounds a sub-1 ms window down to nothing) and the initial fill must be at most the window
|
||||
/// (`<=` — `VUID-...-08358` was relaxed in 1.3.299). Env-free so it pins the default shape —
|
||||
/// the scaled cases belong to whoever sets `PUNKTFUNK_VBV_FRAMES`. Carries the helper's own
|
||||
/// cfg gate (see its note), so it runs on the Linux `vulkan-encode` leg.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[test]
|
||||
fn vbv_window_is_about_one_frame_and_always_legal() {
|
||||
// The house default is ~1 frame interval, not the 1000 ms the Vulkan backend hardwired.
|
||||
assert_eq!(vbv_window_ms(60).0, 17); // 16.67 ms
|
||||
assert_eq!(vbv_window_ms(30).0, 33);
|
||||
assert_eq!(vbv_window_ms(240).0, 4);
|
||||
for fps in [1, 24, 30, 60, 120, 144, 240, 480, 1000, 4000, u32::MAX] {
|
||||
let (window, initial) = vbv_window_ms(fps);
|
||||
assert!(window > 0, "virtualBufferSizeInMs must be > 0 (fps {fps})");
|
||||
assert!(
|
||||
initial <= window,
|
||||
"initialVirtualBufferSizeInMs must be <= virtualBufferSizeInMs (fps {fps})"
|
||||
);
|
||||
}
|
||||
// fps 0 must not divide by zero — `open` clamps, but the helper is called directly too.
|
||||
assert!(vbv_window_ms(0).0 > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
@@ -477,6 +645,26 @@ mod tests {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
/// PyroWave's hard cap is the rate controller's 16-bit block index, not just
|
||||
/// `max_dimension()`. Checked at 4:2:0 (the most permissive chroma), because a mode that
|
||||
/// cannot fit there cannot fit at any chroma — and because the negotiator's 4:4:4 → 4:2:0
|
||||
/// downgrade delivers oversized modes to the encoder AS 4:2:0. HEVC/AV1 at the same
|
||||
/// dimensions must stay unaffected.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn pyrowave_rejects_modes_past_the_rdo_block_index() {
|
||||
// Fits: 8K 4:2:0 is 49125 blocks.
|
||||
assert!(validate_dimensions(Codec::PyroWave, 7680, 4320).is_ok());
|
||||
// Does not fit at 4:2:0 (73728 / 98304 blocks) — must be refused even though both are
|
||||
// within `Codec::PyroWave.max_dimension()` (8192).
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 6144).is_err());
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 8192).is_err());
|
||||
// The same modes remain legal for the H.26x/AV1 codecs, which have no such rate
|
||||
// controller — the cap must not leak across codecs.
|
||||
assert!(validate_dimensions(Codec::H265, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(Codec::Av1, 8192, 6144).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
|
||||
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
@@ -413,57 +412,6 @@ impl NvencEncoder {
|
||||
None
|
||||
};
|
||||
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
|
||||
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
|
||||
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
|
||||
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
|
||||
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
@@ -491,15 +439,19 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
|
||||
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
|
||||
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
let pix_rate = width as u64 * height as u64 * fps as u64;
|
||||
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||
match split.as_deref() {
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
@@ -549,6 +501,84 @@ impl NvencEncoder {
|
||||
);
|
||||
}
|
||||
|
||||
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched), and the packed 3-bpp expand (RGB24/BGR24→rgb0/bgr0).
|
||||
//
|
||||
// The expand used to be a hand-written per-pixel loop in `submit_cpu`: `w*h` iterations,
|
||||
// each building two bounds-checked sub-slices for a 3-byte copy — a shape LLVM will not
|
||||
// vectorise into the byte shuffle it is, on the COMMON CPU path (the portal and wlroots
|
||||
// both commonly fixate packed 24-bit RGB, and pf-capture offers it first). swscale's
|
||||
// packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24
|
||||
// through them, and this file already owned the context lifecycle — so the change is net
|
||||
// subtractive. The three are mutually exclusive by construction: `expand` is only ever
|
||||
// true on the packed-RGB 4:2:0 path (see `nvenc_pixel`/`expand` above), never with
|
||||
// `want_444`, so one context serves whichever applies.
|
||||
//
|
||||
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers ready
|
||||
// CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10 || expand) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||
// here would silently range-convert every packed-RGB session, which is exactly what the
|
||||
// module header promises does not happen ("no colour math").
|
||||
if want_444 || want_hdr10 {
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for
|
||||
// HDR — matching the VUI written above) are process-lifetime libswscale statics,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads them and writes
|
||||
// scalar CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame = if cuda {
|
||||
None
|
||||
} else {
|
||||
@@ -561,7 +591,6 @@ impl NvencEncoder {
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -591,6 +620,9 @@ impl NvencEncoder {
|
||||
impl Encoder for NvencEncoder {
|
||||
fn caps(&self) -> super::EncoderCaps {
|
||||
super::EncoderCaps {
|
||||
// libav NVENC hands the frame straight to the encoder — `frame.cursor` is never read,
|
||||
// so a cursor-as-metadata session loses its pointer on this backend (audit finding).
|
||||
blends_cursor: false,
|
||||
// 4:4:4 iff this session opened FREXT — the CPU swscale path or the zero-copy GPU
|
||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.want_444,
|
||||
@@ -695,8 +727,10 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
||||
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
|
||||
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||
// conversion at all — just a row copy honouring the destination stride.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
let frame = self
|
||||
.frame
|
||||
@@ -705,10 +739,13 @@ impl NvencEncoder {
|
||||
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
||||
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
||||
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its
|
||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
|
||||
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
|
||||
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
|
||||
// YUV444P, 2 for P010, 1 packed plane for `rgb0`/`bgr0` — so swscale writes exactly the
|
||||
// planes it allocated, at the strides it reports. All pointers are live locals for this
|
||||
// synchronous call; the encoder runs only on this thread (`unsafe impl Send`), so no
|
||||
// aliasing/race.
|
||||
unsafe {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
@@ -724,7 +761,7 @@ impl NvencEncoder {
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
@@ -733,7 +770,7 @@ impl NvencEncoder {
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
@@ -742,18 +779,9 @@ impl NvencEncoder {
|
||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
||||
let dst = frame.data_mut(0);
|
||||
if self.expand {
|
||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
||||
for y in 0..h {
|
||||
let s = &bytes[y * src_row..y * src_row + src_row];
|
||||
let drow = &mut dst[y * stride..y * stride + w * 4];
|
||||
for x in 0..w {
|
||||
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
|
||||
drow[x * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
|
||||
{
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
|
||||
// to live here as a per-pixel loop is now swscale's job (see the branch above).
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||
@@ -876,6 +904,58 @@ impl Drop for NvencEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
|
||||
/// an encoder open it *expects* to fail.
|
||||
///
|
||||
/// libav's log level is one process-global `int`, and the probes race each other for real: the
|
||||
/// NVENC and VAAPI 4:4:4/10-bit probes are reached from `/serverinfo` and from session bring-up.
|
||||
/// Two overlapping save/restore pairs interleave as get(INFO) → get(FATAL) → set(INFO) →
|
||||
/// set(FATAL), and the process is then pinned at `AV_LOG_FATAL` for good — every later libav
|
||||
/// diagnostic silently dropped, which is precisely the logging you want when a stream later fails
|
||||
/// to open. The probes run process-once and already cost a real encoder open, so serialising them
|
||||
/// costs nothing measurable.
|
||||
static LIBAV_LOG_LEVEL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// RAII quiet-window over libav's global log level: drops it to `AV_LOG_FATAL` on construction and
|
||||
/// restores the previous level on drop, holding [`LIBAV_LOG_LEVEL`] for the whole window.
|
||||
///
|
||||
/// Callers must have completed `ffmpeg::init()` first. Not re-entrant — no probe may construct a
|
||||
/// second guard while holding one (none do; the probe bodies only reach encoder-open helpers).
|
||||
/// `pub(crate)` so the VAAPI probes share the one lock: they race the NVENC probes on the same
|
||||
/// global.
|
||||
pub(crate) struct QuietLibavLog {
|
||||
prev: c_int,
|
||||
// Held for the lifetime of the guard. `Drop for QuietLibavLog` runs before the struct's fields
|
||||
// are dropped, so the restore below still happens under the lock.
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl QuietLibavLog {
|
||||
pub(crate) fn new() -> Self {
|
||||
// Poison-tolerant: a probe that panicked mid-window already restored the level via `Drop`,
|
||||
// and refusing the lock forever afterwards would be a worse outcome than proceeding.
|
||||
let lock = LIBAV_LOG_LEVEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
// SAFETY: libav is initialized by the caller; `av_log_{get,set}_level` only read/write the
|
||||
// global int level (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
Self { prev, _lock: lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QuietLibavLog {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: restore the saved global level (scalar arg, no pointers); libav was initialized
|
||||
// before this guard was constructed.
|
||||
unsafe { ffi::av_log_set_level(self.prev) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range
|
||||
/// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`]
|
||||
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
||||
@@ -889,14 +969,9 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Quiet ffmpeg's open error on a GPU that lacks 4:4:4 — the probe failing is an expected outcome.
|
||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
||||
// (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::Bgra,
|
||||
640,
|
||||
@@ -907,10 +982,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||
@@ -926,14 +998,9 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Quiet ffmpeg's open error on a GPU that lacks 10-bit — the probe failing is an expected outcome.
|
||||
// SAFETY: libav initialized above; `av_log_{get,set}_level` only read/write the global int level
|
||||
// (no pointer args) and are always sound post-init.
|
||||
let prev = unsafe {
|
||||
let p = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
p
|
||||
};
|
||||
let ok = NvencEncoder::open(
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
640,
|
||||
@@ -944,10 +1011,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey,
|
||||
LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -268,13 +269,19 @@ fn async_retrieve_requested() -> bool {
|
||||
|
||||
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||
/// `2..=POOL-1` — a bitstream must never be reused mid-encode, and the input ring is the same
|
||||
/// depth). Mirrors the Windows knob exactly.
|
||||
/// depth). Mirrors the Windows knob exactly, memoization included: this is the backpressure
|
||||
/// **loop condition** in `submit`, so an engaged two-thread session re-read the environment once
|
||||
/// per spin. The default session never pays it (the condition short-circuits on `async_rt`), which
|
||||
/// is why the audit's severity ranking for this site was inverted — but an escalated one did.
|
||||
fn async_inflight_cap() -> usize {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
||||
@@ -595,6 +602,12 @@ pub struct NvencCudaEncoder {
|
||||
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||
want_async: bool,
|
||||
/// A de-escalation request ([`Encoder::set_pipelined(false)`]) waiting for its safe point:
|
||||
/// the next drained moment tears the session down and lazily re-inits SYNC (IO-stream
|
||||
/// binding and sub-frame chunking re-arm at that re-init). Distinct from `!want_async` —
|
||||
/// an operator-forced async session (`PUNKTFUNK_NVENC_ASYNC=1`) also has `want_async`
|
||||
/// false, and a de-escalation must never tear THAT down.
|
||||
want_sync: bool,
|
||||
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
||||
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
||||
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
||||
@@ -702,6 +715,7 @@ impl NvencCudaEncoder {
|
||||
last_rfi_range: None,
|
||||
async_rt: None,
|
||||
want_async: false,
|
||||
want_sync: false,
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
slices: 1,
|
||||
@@ -735,6 +749,29 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`maybe_engage_async`](Self::maybe_engage_async)'s inverse — wind the escalated pipelined
|
||||
/// retrieve back at a safe point: nothing in flight, then a clean session rebuild whose lazy
|
||||
/// SYNC re-init restores the IO-stream binding and re-arms sub-frame chunking (the two
|
||||
/// latency features the escalation traded away). No-op until
|
||||
/// [`want_sync`](Self::want_sync) is set and `pending` drains.
|
||||
fn maybe_disengage_async(&mut self) {
|
||||
if !self.want_sync || self.async_rt.is_none() || !self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.want_sync = false;
|
||||
if self.inited {
|
||||
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight (and nothing queued
|
||||
// to the retrieve thread); `teardown` joins the retrieve thread and handles exactly
|
||||
// this live-session state — the next submit lazily re-inits sync.
|
||||
unsafe { self.teardown() };
|
||||
tracing::info!(
|
||||
"NVENC pipelined-retrieve de-escalation: rebuilding the session with the sync \
|
||||
retrieve (IO-stream binding and sub-frame chunking restored); next frame opens \
|
||||
with an IDR"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||
unsafe fn teardown(&mut self) {
|
||||
if self.encoder.is_null() {
|
||||
@@ -839,6 +876,9 @@ impl NvencCudaEncoder {
|
||||
e,
|
||||
));
|
||||
}
|
||||
// The handshake with the kernel module just succeeded — from here on, an
|
||||
// `NV_ENC_ERR_INVALID_VERSION` in this process cannot be a driver version skew.
|
||||
nvenc_status::note_session_opened();
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
|
||||
@@ -1000,6 +1040,23 @@ impl NvencCudaEncoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared
|
||||
/// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once
|
||||
/// `cu_ctx` is bound (`init_session` start), which every caller is downstream of.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
CeilingKey {
|
||||
gpu: self.cu_ctx as u64,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
@@ -1019,6 +1076,7 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||
}
|
||||
nvenc_status::note_session_opened();
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -1074,58 +1132,71 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
||||
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
|
||||
// [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate).
|
||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
||||
|
||||
let mut probe = self.try_open_session(requested_bps, split_mode);
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the cached codec-level ceiling — \
|
||||
opening at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(target_bps, split_mode);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(requested_bps, split_mode);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split`
|
||||
// tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(requested_bps, no_split) {
|
||||
if let Ok(e) = self.try_open_session(target_bps, no_split) {
|
||||
tracing::warn!(
|
||||
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
||||
);
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(mid, split_mode) {
|
||||
match self.try_open_session(mid, used_split) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -1134,18 +1205,30 @@ impl NvencCudaEncoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(FLOOR_BPS, split_mode)
|
||||
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(FLOOR_BPS, used_split) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self.try_open_session(FLOOR_BPS, no_split).context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -1153,15 +1236,13 @@ impl NvencCudaEncoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
||||
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
|
||||
// rejects it, and the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
self.split_mode = used_split;
|
||||
|
||||
// Output bitstream pool.
|
||||
for _ in 0..POOL {
|
||||
@@ -1345,6 +1426,10 @@ impl NvencCudaEncoder {
|
||||
mbps = self.bitrate_bps / 1_000_000,
|
||||
codec = ?self.codec_guid,
|
||||
fmt = ?self.buffer_fmt,
|
||||
// The FINAL split mode (post any rejection fallback) at INFO — journals run
|
||||
// INFO+, and "did 4K120 actually split across engines?" was undiagnosable from
|
||||
// a user log without it (Windows only had a debug! at selection time).
|
||||
split_mode = self.split_mode,
|
||||
"NVENC CUDA session ready"
|
||||
);
|
||||
Ok(())
|
||||
@@ -1426,9 +1511,10 @@ impl Encoder for NvencCudaEncoder {
|
||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||
),
|
||||
};
|
||||
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
||||
// (nothing in flight after the previous poll drained).
|
||||
// A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
|
||||
// submit-side safe point (nothing in flight after the previous poll drained).
|
||||
self.maybe_engage_async();
|
||||
self.maybe_disengage_async();
|
||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||
let new_fmt = buffer_format(buf);
|
||||
@@ -1560,25 +1646,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
} else {
|
||||
self.cursor_blend_warned = false;
|
||||
// TEMP KWin composite probe (DROP BEFORE MERGE): prove the blend dispatches
|
||||
// and with what geometry.
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering as ProbeOrd};
|
||||
static PROBE_BLEND: AtomicU64 = AtomicU64::new(0);
|
||||
let n = PROBE_BLEND.fetch_add(1, ProbeOrd::Relaxed) + 1;
|
||||
if n == 1 || n % 512 == 0 {
|
||||
tracing::info!(
|
||||
n,
|
||||
fmt = ?self.buffer_fmt,
|
||||
ov_x = ov.x,
|
||||
ov_y = ov.y,
|
||||
ov_w = ov.w,
|
||||
ov_h = ov.h,
|
||||
visible = ov.visible,
|
||||
"cursor blend probe: vulkan dispatch submitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !self.cursor_blend_warned {
|
||||
self.cursor_blend_warned = true;
|
||||
@@ -1736,12 +1803,25 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||
if !on {
|
||||
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
||||
// De-escalation (the v2 of escalate-and-hold): latch the wind-back intent; the
|
||||
// switch itself happens at the next drained safe point
|
||||
// ([`maybe_disengage_async`](Self::maybe_disengage_async)) — the caller polls
|
||||
// this same method until it reports inactive.
|
||||
if async_retrieve_env() == Some(true) {
|
||||
// Operator pinned async on — de-escalation must not undo an explicit choice.
|
||||
return self.want_async || self.async_rt.is_some();
|
||||
}
|
||||
if self.want_async || self.async_rt.is_some() {
|
||||
self.want_async = false;
|
||||
self.want_sync = true;
|
||||
self.maybe_disengage_async();
|
||||
}
|
||||
return self.want_async || self.async_rt.is_some();
|
||||
}
|
||||
if async_retrieve_env() == Some(false) {
|
||||
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
||||
}
|
||||
self.want_sync = false; // latest intent wins — cancel a pending wind-back
|
||||
if !self.want_async && self.async_rt.is_none() {
|
||||
self.want_async = true;
|
||||
self.maybe_engage_async();
|
||||
@@ -1751,6 +1831,8 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
||||
blends_cursor: true,
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
chroma_444: self.chroma_444,
|
||||
@@ -1765,48 +1847,46 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
||||
// Range validity, covering-range dedup, DPB window and clamp all live in
|
||||
// `nvenc_core::plan_range_recovery` — one policy for both direct-NVENC backends; only the
|
||||
// session gate and the driver loop are this backend's.
|
||||
if self.encoder.is_null() || !self.rfi_supported {
|
||||
return false;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — re-arm the anchor (the previous
|
||||
// anchor AU may itself have been lost) but skip the driver calls.
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||
// Already invalidated a covering range for this loss event — re-arm the anchor (the
|
||||
// previous anchor AU may itself have been lost) but skip the driver calls.
|
||||
RangePlan::Covered => {
|
||||
self.pending_anchor = true;
|
||||
return true;
|
||||
true
|
||||
}
|
||||
}
|
||||
// The DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`; a lost frame older than that can't be
|
||||
// invalidated, so the only correct recovery is an IDR.
|
||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return false;
|
||||
}
|
||||
let last = last.min(self.frame_idx - 1);
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// Each input's `inputTimeStamp` is the WIRE frame index (pinned by `submit_indexed`), so the
|
||||
// client's lost-frame range maps 1:1 onto the timestamps NVENC invalidates here.
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime table; `self.encoder`
|
||||
// was checked non-null and is the live session; this runs on the encode thread (no concurrent
|
||||
// NVENC use). Each `ts` is clamped to `[oldest_in_dpb, frame_idx - 1]`, naming a frame still
|
||||
// in the DPB; the call passes only that `u64` (no struct).
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
RangePlan::Decline => false,
|
||||
RangePlan::Invalidate { first, last } => {
|
||||
// Each input's `inputTimeStamp` is the WIRE frame index (pinned by
|
||||
// `submit_indexed`), so the client's lost-frame range maps 1:1 onto the timestamps
|
||||
// NVENC invalidates here.
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime table;
|
||||
// `self.encoder` was checked non-null and is the live session; this runs on the
|
||||
// encode thread (no concurrent NVENC use). The plan clamped each `ts` to
|
||||
// `[oldest_in_dpb, frame_idx - 1]`, naming a frame still in the DPB; the call
|
||||
// passes only that `u64` (no struct).
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships
|
||||
// with `recovery_anchor` and the client lifts its post-loss freeze on it.
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships with
|
||||
// `recovery_anchor` and the client lifts its post-loss freeze on it.
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
@@ -2061,6 +2141,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
||||
// encode thread with no NVENC call in flight (the session loop calls this between
|
||||
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
||||
@@ -2108,6 +2197,12 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -2165,6 +2260,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2241,6 +2337,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA 4:4:4 session");
|
||||
|
||||
@@ -2286,6 +2383,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2343,6 +2441,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
) else {
|
||||
eprintln!(
|
||||
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
||||
@@ -2369,6 +2468,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA encoder")
|
||||
}
|
||||
@@ -2403,6 +2503,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open");
|
||||
for f in 0..4u32 {
|
||||
@@ -2540,6 +2641,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
@@ -2581,6 +2683,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
// Steady sync frames first (stream-ordered mode).
|
||||
@@ -2660,6 +2763,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2765,6 +2869,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,22 +28,18 @@ use ffmpeg::format::Pixel;
|
||||
use ffmpeg::{codec, encoder, Dictionary};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
}
|
||||
|
||||
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
|
||||
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
||||
/// default.
|
||||
@@ -71,21 +67,38 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Which VAAPI entrypoint mode opened successfully, cached per codec (index = [`lp_idx`]):
|
||||
/// 0 = unknown, 1 = default (full-feature `EncSlice`), 2 = low-power (`EncSliceLP`/VDEnc).
|
||||
/// Modern Intel (Gen12+/Arc) removed the full-feature encode entrypoints, so the default open
|
||||
/// fails there and only `low_power=1` works; AMD (radeonsi) is the reverse. Caching the resolved
|
||||
/// mode lets later sessions/probes skip the known-failing attempt (and its libav error spew).
|
||||
static LP_MODE: [AtomicU8; 3] = [AtomicU8::new(0), AtomicU8::new(0), AtomicU8::new(0)];
|
||||
/// Which VAAPI entrypoint mode opened successfully: 1 = default (full-feature `EncSlice`),
|
||||
/// 2 = low-power (`EncSliceLP`/VDEnc). Modern Intel (Gen12+/Arc) removed the full-feature encode
|
||||
/// entrypoints, so the default open fails there and only `low_power=1` works; AMD (radeonsi) is the
|
||||
/// reverse. Caching the resolved mode lets later sessions/probes skip the known-failing attempt
|
||||
/// (and its libav error spew).
|
||||
///
|
||||
/// Keyed on **(render node, codec, bit depth)**, and every part of that key is load-bearing:
|
||||
///
|
||||
/// * The render node, because the entrypoint is a property of the DEVICE libva opens — and
|
||||
/// `render_node()` follows the web-console GPU preference. This used to be a process-global array
|
||||
/// keyed by codec alone, which made it a session-killer rather than a staleness bug: once a mode
|
||||
/// is latched the open tries exactly ONE mode and the `[false, true]` fallback is gone. Latch
|
||||
/// low-power on an Intel Arc, switch the preference to an AMD dGPU, and every VAAPI open there
|
||||
/// passes `low_power=1` — which radeonsi rejects — with no full-feature retry, for the process
|
||||
/// lifetime: the probe reports all-false AND the session's own encoder open fails.
|
||||
/// Keyed on the node rather than `pf_gpu::selection_key()` on purpose — the node is literally what
|
||||
/// `render_node()` hands libva, so key and device cannot describe different GPUs.
|
||||
/// * The bit depth, because Main10 and 8-bit can resolve to different entrypoints on the same
|
||||
/// device; one shared slot let an 8-bit answer pin the 10-bit open (HDR under-advertisement).
|
||||
static LP_MODE: OnceLock<Mutex<HashMap<LpKey, u8>>> = OnceLock::new();
|
||||
|
||||
fn lp_idx(codec: Codec) -> usize {
|
||||
match codec {
|
||||
Codec::H264 => 0,
|
||||
Codec::H265 => 1,
|
||||
Codec::Av1 => 2,
|
||||
// Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend.
|
||||
Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"),
|
||||
}
|
||||
/// (render-node path, codec label, 10-bit) — see [`LP_MODE`].
|
||||
type LpKey = (String, &'static str, bool);
|
||||
|
||||
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
|
||||
/// so a GPU-preference change is picked up on the next open.
|
||||
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
|
||||
(
|
||||
render_node().to_string_lossy().into_owned(),
|
||||
codec.label(),
|
||||
ten_bit,
|
||||
)
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
||||
@@ -116,11 +129,16 @@ unsafe fn open_vaapi_encoder(
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
ten_bit: bool,
|
||||
) -> Result<encoder::video::Encoder> {
|
||||
let idx = lp_idx(codec);
|
||||
let key = lp_key(codec, ten_bit);
|
||||
let cached = LP_MODE
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map(|m| m.get(&key).copied().unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
let modes: &[bool] = match low_power_override() {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match LP_MODE[idx].load(Ordering::Relaxed) {
|
||||
None => match cached {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
@@ -140,7 +158,9 @@ unsafe fn open_vaapi_encoder(
|
||||
lp,
|
||||
) {
|
||||
Ok(enc) => {
|
||||
LP_MODE[idx].store(if lp { 2 } else { 1 }, Ordering::Relaxed);
|
||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||
m.insert(key.clone(), if lp { 2 } else { 1 });
|
||||
}
|
||||
if lp {
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
@@ -253,20 +273,20 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
|
||||
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
|
||||
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
|
||||
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
|
||||
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
|
||||
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
|
||||
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
|
||||
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||
// ffmpeg's "No VA display found" error for the probe. Held until the function returns, so the
|
||||
// level is restored after the open either way. Shares one lock with the NVENC probes, which
|
||||
// race this one on the same libav global (see [`crate::linux::QuietLibavLog`]).
|
||||
let _quiet = crate::linux::QuietLibavLog::new();
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `VaapiHw::new` (an
|
||||
// `unsafe fn`) builds a VAAPI device + NV12 frames pool from the literal NV12/640x480/pool=2
|
||||
// args and hands back a RAII handle that unrefs both `AVBufferRef`s on drop.
|
||||
// `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — the two
|
||||
// non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them into the encoder; `hw`
|
||||
// is a live local for the whole match arm, so the borrows outlive the synchronous call, and
|
||||
// both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||
unsafe {
|
||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
||||
let prev = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -279,9 +299,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,18 +315,17 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_{get,set}_level`
|
||||
// only read/write libav's global integer log level (no pointer args). `VaapiHw::new` (an
|
||||
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
||||
// error for the probe. Held until the function returns, so the level is restored after the open
|
||||
// either way, and shared with the other probes (see [`crate::linux::QuietLibavLog`]).
|
||||
let _quiet = crate::linux::QuietLibavLog::new();
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `VaapiHw::new` (an
|
||||
// `unsafe fn`) builds a VAAPI device + P010 frames pool from the literal args and hands back a
|
||||
// RAII handle; `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` —
|
||||
// the two non-null refs `VaapiHw::new` just created, live locals for the whole match arm — and
|
||||
// `av_buffer_ref`s them into the probe encoder. Both `hw` and the encoder drop (RAII) at arm end.
|
||||
unsafe {
|
||||
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
|
||||
// error for the probe, then restore the level.
|
||||
let prev = ffi::av_log_get_level();
|
||||
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
|
||||
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -321,9 +338,7 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,7 +949,8 @@ impl DmabufInner {
|
||||
// `Box` puts it on the heap with a unique owner.
|
||||
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
||||
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
|
||||
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
|
||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||
@@ -950,7 +966,17 @@ impl DmabufInner {
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
desc.nb_objects = 1;
|
||||
desc.objects[0].fd = dmabuf.fd.as_raw_fd();
|
||||
desc.objects[0].size = 0;
|
||||
// The object's REAL size, not 0. libav does not query it for us — both of its import
|
||||
// paths hand this value straight to libva, as `prime_desc.objects[i].size` on the
|
||||
// PRIME_2 path and `buffer_desc.data_size` on the legacy fallback — so a 0 told every
|
||||
// VA driver the backing object was empty and left it to work the real size out itself.
|
||||
// The drivers this has run on (radeonsi, modern Intel iHD) do; a Gen9 Intel host
|
||||
// answered `vaCreateSurfaces` with VA_STATUS_ERROR_ALLOCATION_FAILED on every single
|
||||
// frame. `lseek(SEEK_END)` is the standard dma-buf size query — the same one the
|
||||
// Vulkan bridge already uses on these fds (`pf_zerocopy::imp::vulkan`). If a kernel
|
||||
// refuses it, keep the old 0 rather than drop a frame we could still have encoded.
|
||||
let obj_size = libc::lseek(dmabuf.fd.as_raw_fd(), 0, libc::SEEK_END);
|
||||
desc.objects[0].size = if obj_size > 0 { obj_size as _ } else { 0 };
|
||||
desc.objects[0].format_modifier = dmabuf.modifier;
|
||||
desc.nb_layers = 1;
|
||||
desc.layers[0].format = self.fourcc;
|
||||
@@ -999,8 +1025,18 @@ impl DmabufInner {
|
||||
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
||||
);
|
||||
ffi::av_frame_free(&mut drm);
|
||||
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||
// permanent penalty for a transient fault.
|
||||
if r < 0 {
|
||||
bail!("av_buffersrc_add_frame failed ({r})");
|
||||
let e = format!("av_buffersrc_add_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
t_push = t0.elapsed();
|
||||
let mut nv12 = ffi::av_frame_alloc();
|
||||
@@ -1010,8 +1046,11 @@ impl DmabufInner {
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
bail!("av_buffersink_get_frame failed ({r})");
|
||||
let e = format!("av_buffersink_get_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||
t_pull = t0.elapsed() - t_push;
|
||||
(*nv12).pts = pts;
|
||||
(*nv12).pict_type = if idr {
|
||||
|
||||
@@ -47,6 +47,15 @@ pub const PRIMARY_REF_NONE: u8 = 7;
|
||||
/// `VK_VIDEO_ENCODE_AV1_SUPERBLOCK_SIZE_128_BIT_KHR` (bit 1 of the superblock-size flags).
|
||||
pub const SUPERBLOCK_SIZE_128: u32 = 0x2;
|
||||
|
||||
// `VkVideoEncodeAV1CapabilityFlagBitsKHR` — the two that decide whether the encode source may be a
|
||||
// different size from the declared frame. Both absent on RADV PHOENIX.
|
||||
/// Without this, the source's `codedExtent` MUST equal the sequence header's
|
||||
/// `max_frame_{width,height}_minus_1 + 1` (`VUID-vkCmdEncodeVideoKHR-flags-10324`).
|
||||
pub const CAPABILITY_FRAME_SIZE_OVERRIDE: u32 = 0x0000_0008;
|
||||
/// Without this, EVERY reference slot's `codedExtent` MUST equal the source's
|
||||
/// (`VUID-vkCmdEncodeVideoKHR-flags-10325`).
|
||||
pub const CAPABILITY_MOTION_VECTOR_SCALING: u32 = 0x0000_0010;
|
||||
|
||||
// `VkVideoEncodeAV1PredictionModeKHR`
|
||||
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
|
||||
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
|
||||
@@ -498,3 +507,366 @@ pub struct StdVideoEncodeAV1OperatingPointInfo {
|
||||
pub fn stype(raw: i32) -> vk::StructureType {
|
||||
vk::StructureType::from_raw(raw)
|
||||
}
|
||||
|
||||
// ---------- ABI layout guard ----------
|
||||
//
|
||||
// These structs are hand-copied and handed to the driver through raw `p_next` chains, so nothing in
|
||||
// the type system relates them to the C definitions any more: an edit that inserts, drops, widens or
|
||||
// re-pads a field is not a compile error, it is the driver reading our bytes at the wrong offsets.
|
||||
// The assertions below are the missing compile error. They are `const` rather than `#[cfg(test)]`
|
||||
// (the shape `amf.rs` uses) so they hold in every build, including the shipped one, and on any
|
||||
// target this module compiles for.
|
||||
//
|
||||
// What they catch: a changed field width, an inserted or removed field, a changed array length, a
|
||||
// padding assumption that only holds on one target. What they CANNOT catch: swapping two fields of
|
||||
// the same type — offsets are unchanged. That case is only caught by reading the registry, so the
|
||||
// field order here was diffed against `vulkan_core.h` and `vk_video/vulkan_video_codec_av1std_encode.h`
|
||||
// (Vulkan-Headers `main`, 2026-07-25) when these assertions were written, along with every `ST_*`,
|
||||
// flag-bit and enum value above; the bitfield member order is pinned by the test module below.
|
||||
//
|
||||
// Deliberately duplicated in `vk_valve_rgb.rs` rather than shared: both modules exist to be deleted
|
||||
// wholesale once `ash` ships these bindings, and a shared helper would make deleting one break the
|
||||
// other.
|
||||
macro_rules! assert_abi_layout {
|
||||
($t:ty { size: $size:expr, align: $align:expr $(, $field:ident @ $off:expr)* $(,)? }) => {
|
||||
const _: () = {
|
||||
assert!(
|
||||
::core::mem::size_of::<$t>() == $size,
|
||||
concat!(stringify!($t), ": size does not match the C ABI")
|
||||
);
|
||||
assert!(
|
||||
::core::mem::align_of::<$t>() == $align,
|
||||
concat!(stringify!($t), ": alignment does not match the C ABI")
|
||||
);
|
||||
$(assert!(
|
||||
::core::mem::offset_of!($t, $field) == $off,
|
||||
concat!(stringify!($t), ".", stringify!($field), ": offset does not match the C ABI")
|
||||
);)*
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Std encode structs. The three `*Flags` types are a single C `uint32_t` of bitfields, so only their
|
||||
// size and alignment are layout-checkable here; their member order is covered by `abi_tests`.
|
||||
assert_abi_layout!(StdVideoEncodeAV1PictureInfoFlags { size: 4, align: 4 });
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1PictureInfo {
|
||||
size: 152, align: 8,
|
||||
flags @ 0,
|
||||
frame_type @ 4,
|
||||
frame_presentation_time @ 8,
|
||||
current_frame_id @ 12,
|
||||
order_hint @ 16,
|
||||
primary_ref_frame @ 17,
|
||||
refresh_frame_flags @ 18,
|
||||
coded_denom @ 19,
|
||||
render_width_minus_1 @ 20,
|
||||
render_height_minus_1 @ 22,
|
||||
interpolation_filter @ 24,
|
||||
TxMode @ 28,
|
||||
delta_q_res @ 32,
|
||||
delta_lf_res @ 33,
|
||||
ref_order_hint @ 34,
|
||||
ref_frame_idx @ 42,
|
||||
reserved1 @ 49,
|
||||
delta_frame_id_minus_1 @ 52,
|
||||
pTileInfo @ 80,
|
||||
pQuantization @ 88,
|
||||
pSegmentation @ 96,
|
||||
pLoopFilter @ 104,
|
||||
pCDEF @ 112,
|
||||
pLoopRestoration @ 120,
|
||||
pGlobalMotion @ 128,
|
||||
pExtensionHeader @ 136,
|
||||
pBufferRemovalTimes @ 144,
|
||||
});
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1ReferenceInfoFlags { size: 4, align: 4 });
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1ReferenceInfo {
|
||||
size: 24, align: 8,
|
||||
flags @ 0,
|
||||
RefFrameId @ 4,
|
||||
frame_type @ 8,
|
||||
OrderHint @ 12,
|
||||
reserved1 @ 13,
|
||||
pExtensionHeader @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1ExtensionHeader {
|
||||
size: 2, align: 1,
|
||||
temporal_id @ 0,
|
||||
spatial_id @ 1,
|
||||
});
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1OperatingPointInfoFlags { size: 4, align: 4 });
|
||||
|
||||
assert_abi_layout!(StdVideoEncodeAV1OperatingPointInfo {
|
||||
size: 20, align: 4,
|
||||
flags @ 0,
|
||||
operating_point_idc @ 4,
|
||||
seq_level_idx @ 6,
|
||||
seq_tier @ 7,
|
||||
decoder_buffer_delay @ 8,
|
||||
encoder_buffer_delay @ 12,
|
||||
initial_display_delay_minus_1 @ 16,
|
||||
});
|
||||
|
||||
// KHR extension structs.
|
||||
assert_abi_layout!(VideoEncodeAV1ProfileInfoKHR {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
std_profile @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(PhysicalDeviceVideoEncodeAV1FeaturesKHR {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
video_encode_av1 @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1CapabilitiesKHR {
|
||||
size: 128, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
flags @ 16,
|
||||
max_level @ 20,
|
||||
coded_picture_alignment @ 24,
|
||||
max_tiles @ 32,
|
||||
min_tile_size @ 40,
|
||||
max_tile_size @ 48,
|
||||
superblock_sizes @ 56,
|
||||
max_single_reference_count @ 60,
|
||||
single_reference_name_mask @ 64,
|
||||
max_unidirectional_compound_reference_count @ 68,
|
||||
max_unidirectional_compound_group1_reference_count @ 72,
|
||||
unidirectional_compound_reference_name_mask @ 76,
|
||||
max_bidirectional_compound_reference_count @ 80,
|
||||
max_bidirectional_compound_group1_reference_count @ 84,
|
||||
max_bidirectional_compound_group2_reference_count @ 88,
|
||||
bidirectional_compound_reference_name_mask @ 92,
|
||||
max_temporal_layer_count @ 96,
|
||||
max_spatial_layer_count @ 100,
|
||||
max_operating_points @ 104,
|
||||
min_q_index @ 108,
|
||||
max_q_index @ 112,
|
||||
prefers_gop_remaining_frames @ 116,
|
||||
requires_gop_remaining_frames @ 120,
|
||||
std_syntax_flags @ 124,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1SessionParametersCreateInfoKHR {
|
||||
size: 48, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
p_std_sequence_header @ 16,
|
||||
p_std_decoder_model_info @ 24,
|
||||
std_operating_point_count @ 32,
|
||||
p_std_operating_points @ 40,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1PictureInfoKHR {
|
||||
size: 80, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
prediction_mode @ 16,
|
||||
rate_control_group @ 20,
|
||||
constant_q_index @ 24,
|
||||
p_std_picture_info @ 32,
|
||||
reference_name_slot_indices @ 40,
|
||||
primary_reference_cdf_only @ 68,
|
||||
generate_obu_extension_header @ 72,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1DpbSlotInfoKHR {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
p_std_reference_info @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1RateControlInfoKHR {
|
||||
size: 40, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
flags @ 16,
|
||||
gop_frame_count @ 20,
|
||||
key_frame_period @ 24,
|
||||
consecutive_bipredictive_frame_count @ 28,
|
||||
temporal_layer_count @ 32,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1QIndexKHR {
|
||||
size: 12, align: 4,
|
||||
intra_q_index @ 0,
|
||||
predictive_q_index @ 4,
|
||||
bipredictive_q_index @ 8,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1FrameSizeKHR {
|
||||
size: 12, align: 4,
|
||||
intra_frame_size @ 0,
|
||||
predictive_frame_size @ 4,
|
||||
bipredictive_frame_size @ 8,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1RateControlLayerInfoKHR {
|
||||
size: 64, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
use_min_q_index @ 16,
|
||||
min_q_index @ 20,
|
||||
use_max_q_index @ 32,
|
||||
max_q_index @ 36,
|
||||
use_max_frame_size @ 48,
|
||||
max_frame_size @ 52,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1GopRemainingFrameInfoKHR {
|
||||
size: 32, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
use_gop_remaining_frames @ 16,
|
||||
gop_remaining_intra @ 20,
|
||||
gop_remaining_predictive @ 24,
|
||||
gop_remaining_bipredictive @ 28,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeAV1SessionCreateInfoKHR {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
use_max_level @ 16,
|
||||
max_level @ 20,
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod abi_tests {
|
||||
use super::*;
|
||||
|
||||
/// Assert that `set` lights exactly one bit of the flags word, at `bit`.
|
||||
fn sets_only_bit(
|
||||
storage: __BindgenBitfieldUnit<[u8; 4]>,
|
||||
name: &str,
|
||||
bit: usize,
|
||||
expected_width: usize,
|
||||
) {
|
||||
for probe in 0..32 {
|
||||
let want = probe >= bit && probe < bit + expected_width;
|
||||
assert_eq!(
|
||||
storage.get_bit(probe),
|
||||
want,
|
||||
"`{name}` should occupy bit(s) {bit}..{}, but bit {probe} disagrees",
|
||||
bit + expected_width
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A C bitfield allocates its members from bit 0 upward in declaration order, so the setter for
|
||||
/// the Nth member of `StdVideoEncodeAV1PictureInfoFlags` must write bit N. The array below is
|
||||
/// the member list of `vulkan_video_codec_av1std_encode.h` **in declaration order** — so this
|
||||
/// pins the hand-copied bit indices to the header rather than merely to themselves. A silent
|
||||
/// renumbering here would make the driver read, say, `use_superres` where we meant
|
||||
/// `render_and_frame_size_different`.
|
||||
#[test]
|
||||
fn picture_info_flag_setters_follow_the_c_declaration_order() {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let members: [(&str, fn(&mut StdVideoEncodeAV1PictureInfoFlags)); 29] = [
|
||||
("error_resilient_mode", |f| f.set_error_resilient_mode(1)),
|
||||
("disable_cdf_update", |f| f.set_disable_cdf_update(1)),
|
||||
("use_superres", |f| f.set_use_superres(1)),
|
||||
("render_and_frame_size_different", |f| {
|
||||
f.set_render_and_frame_size_different(1)
|
||||
}),
|
||||
("allow_screen_content_tools", |f| {
|
||||
f.set_allow_screen_content_tools(1)
|
||||
}),
|
||||
("is_filter_switchable", |f| f.set_is_filter_switchable(1)),
|
||||
("force_integer_mv", |f| f.set_force_integer_mv(1)),
|
||||
("frame_size_override_flag", |f| {
|
||||
f.set_frame_size_override_flag(1)
|
||||
}),
|
||||
("buffer_removal_time_present_flag", |f| {
|
||||
f.set_buffer_removal_time_present_flag(1)
|
||||
}),
|
||||
("allow_intrabc", |f| f.set_allow_intrabc(1)),
|
||||
("frame_refs_short_signaling", |f| {
|
||||
f.set_frame_refs_short_signaling(1)
|
||||
}),
|
||||
("allow_high_precision_mv", |f| {
|
||||
f.set_allow_high_precision_mv(1)
|
||||
}),
|
||||
("is_motion_mode_switchable", |f| {
|
||||
f.set_is_motion_mode_switchable(1)
|
||||
}),
|
||||
("use_ref_frame_mvs", |f| f.set_use_ref_frame_mvs(1)),
|
||||
("disable_frame_end_update_cdf", |f| {
|
||||
f.set_disable_frame_end_update_cdf(1)
|
||||
}),
|
||||
("allow_warped_motion", |f| f.set_allow_warped_motion(1)),
|
||||
("reduced_tx_set", |f| f.set_reduced_tx_set(1)),
|
||||
("skip_mode_present", |f| f.set_skip_mode_present(1)),
|
||||
("delta_q_present", |f| f.set_delta_q_present(1)),
|
||||
("delta_lf_present", |f| f.set_delta_lf_present(1)),
|
||||
("delta_lf_multi", |f| f.set_delta_lf_multi(1)),
|
||||
("segmentation_enabled", |f| f.set_segmentation_enabled(1)),
|
||||
("segmentation_update_map", |f| {
|
||||
f.set_segmentation_update_map(1)
|
||||
}),
|
||||
("segmentation_temporal_update", |f| {
|
||||
f.set_segmentation_temporal_update(1)
|
||||
}),
|
||||
("segmentation_update_data", |f| {
|
||||
f.set_segmentation_update_data(1)
|
||||
}),
|
||||
("UsesLr", |f| f.set_UsesLr(1)),
|
||||
("usesChromaLr", |f| f.set_usesChromaLr(1)),
|
||||
("show_frame", |f| f.set_show_frame(1)),
|
||||
("showable_frame", |f| f.set_showable_frame(1)),
|
||||
];
|
||||
|
||||
for (bit, (name, set)) in members.into_iter().enumerate() {
|
||||
let mut flags = StdVideoEncodeAV1PictureInfoFlags {
|
||||
_bitfield_align_1: [],
|
||||
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||
};
|
||||
set(&mut flags);
|
||||
sets_only_bit(flags._bitfield_1, name, bit, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `reserved : 3` closes out the word — bits 29..32. Checking it is what proves the 29 members
|
||||
/// above are the *whole* list: a dropped member would shift `reserved` down and fail here.
|
||||
#[test]
|
||||
fn picture_info_reserved_occupies_the_top_three_bits() {
|
||||
let mut flags = StdVideoEncodeAV1PictureInfoFlags {
|
||||
_bitfield_align_1: [],
|
||||
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||
};
|
||||
flags.set_reserved(0b111);
|
||||
sets_only_bit(flags._bitfield_1, "reserved", 29, 3);
|
||||
}
|
||||
|
||||
/// The same invariant for the two-member reference-info flags word.
|
||||
#[test]
|
||||
fn reference_info_flag_setters_follow_the_c_declaration_order() {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let members: [(&str, fn(&mut StdVideoEncodeAV1ReferenceInfoFlags)); 2] = [
|
||||
("disable_frame_end_update_cdf", |f| {
|
||||
f.set_disable_frame_end_update_cdf(1)
|
||||
}),
|
||||
("segmentation_enabled", |f| f.set_segmentation_enabled(1)),
|
||||
];
|
||||
|
||||
for (bit, (name, set)) in members.into_iter().enumerate() {
|
||||
let mut flags = StdVideoEncodeAV1ReferenceInfoFlags {
|
||||
_bitfield_align_1: [],
|
||||
_bitfield_1: __BindgenBitfieldUnit::new([0u8; 4]),
|
||||
};
|
||||
set(&mut flags);
|
||||
sets_only_bit(flags._bitfield_1, name, bit, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
//! Session/frame **construction** for the Vulkan Video encoder — the unsafe builders
|
||||
//! (`make_frame*`, `make_video_image`, `probe_rgb_direct`) and the parameter-set bitstream
|
||||
//! writers (`build_parameters_h265`/`_av1`, the AV1 sequence-header OBU). Split from
|
||||
//! `vulkan_video.rs` (WP7.5) the way `amf_sys.rs` was split from `amf.rs`: a `#[path]` child
|
||||
//! module, so this file sees the parent's private items (`Frame` and friends) with zero
|
||||
//! visibility churn, and ~800 lines of construction `unsafe` get their own review surface.
|
||||
//! Steady-state encode logic stays in the parent.
|
||||
|
||||
// The parent's whole item namespace (Frame, the consts, sibling helpers) — the point of the
|
||||
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
|
||||
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
|
||||
use super::*;
|
||||
use crate::vk_util::{find_mem, make_plain_image, make_view};
|
||||
use anyhow::{bail, Result};
|
||||
use ash::vk;
|
||||
use std::ffi::c_void;
|
||||
|
||||
pub(super) fn align_up(v: u64, a: u64) -> u64 {
|
||||
v.div_ceil(a) * a
|
||||
}
|
||||
|
||||
/// Probe for the RGB-direct encode source (design/vulkan-rgb-direct-encode.md): can this device
|
||||
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the 709-narrow CSC,
|
||||
/// via `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
|
||||
/// `Ok((x_offset, y_offset))` carries the chroma-siting bits a session must be created with
|
||||
/// (the preferred available bit per axis); `Err` is the first missing requirement, logged as
|
||||
/// the open-time verdict.
|
||||
pub(super) unsafe fn probe_rgb_direct(
|
||||
instance: &ash::Instance,
|
||||
vq_inst: &ash::khr::video_queue::Instance,
|
||||
pd: vk::PhysicalDevice,
|
||||
codec_op: vk::VideoCodecOperationFlagsKHR,
|
||||
av1: bool,
|
||||
) -> Result<(u32, u32), &'static str> {
|
||||
use crate::vk_av1_encode as av1b;
|
||||
use crate::vk_valve_rgb as vrgb;
|
||||
// 1. The device extension must exist (Mesa >= 26.0 AND the VCN has an EFC block).
|
||||
let Ok(exts) = instance.enumerate_device_extension_properties(pd) else {
|
||||
return Err("probe-failed(ext-enum)");
|
||||
};
|
||||
if !exts
|
||||
.iter()
|
||||
.any(|e| std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == vrgb::EXTENSION_NAME)
|
||||
{
|
||||
return Err("no-ext(mesa<26.0-or-no-efc)");
|
||||
}
|
||||
// 2. Feature bit.
|
||||
let mut feat = vrgb::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
||||
s_type: vrgb::stype(vrgb::ST_PHYSICAL_DEVICE_FEATURES),
|
||||
p_next: std::ptr::null_mut(),
|
||||
video_encode_rgb_conversion: vk::FALSE,
|
||||
};
|
||||
let mut f2 = vk::PhysicalDeviceFeatures2 {
|
||||
p_next: &mut feat as *mut _ as *mut c_void,
|
||||
..Default::default()
|
||||
};
|
||||
instance.get_physical_device_features2(pd, &mut f2);
|
||||
if feat.video_encode_rgb_conversion == vk::FALSE {
|
||||
return Err("no-feature");
|
||||
}
|
||||
// 3. Capabilities under the rgb-chained profile — the conversion must cover the compute
|
||||
// CSC's colour math (rgb2yuv.comp: BT.709, narrow range; chroma siting is looser, see
|
||||
// below). The profile chain is the same one every rgb-direct consumer presents.
|
||||
let mut ps = RgbProfileStack::new(codec_op);
|
||||
let profile = *ps.wire(av1);
|
||||
let mut rgb_caps = vrgb::VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||
s_type: vrgb::stype(vrgb::ST_CAPABILITIES),
|
||||
p_next: std::ptr::null_mut(),
|
||||
rgb_models: 0,
|
||||
rgb_ranges: 0,
|
||||
x_chroma_offsets: 0,
|
||||
y_chroma_offsets: 0,
|
||||
};
|
||||
let mut h265_caps = vk::VideoEncodeH265CapabilitiesKHR::default();
|
||||
let mut av1_caps: av1b::VideoEncodeAV1CapabilitiesKHR = std::mem::zeroed();
|
||||
av1_caps.s_type = av1b::stype(av1b::ST_CAPABILITIES);
|
||||
let mut enc_caps = vk::VideoEncodeCapabilitiesKHR::default();
|
||||
let mut caps = vk::VideoCapabilitiesKHR::default();
|
||||
if av1 {
|
||||
av1_caps.p_next = &mut rgb_caps as *mut _ as *mut c_void;
|
||||
enc_caps.p_next = &mut av1_caps as *mut _ as *mut c_void;
|
||||
} else {
|
||||
h265_caps.p_next = &mut rgb_caps as *mut _ as *mut c_void;
|
||||
enc_caps.p_next = &mut h265_caps as *mut _ as *mut c_void;
|
||||
}
|
||||
caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
|
||||
let r = (vq_inst.fp().get_physical_device_video_capabilities_khr)(pd, &profile, &mut caps);
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err("no-rgb-profile(caps)");
|
||||
}
|
||||
// Colour model + range must match the shader exactly (709 narrow). Chroma siting is looser
|
||||
// BY ON-GLASS FINDING (RADV 26.0.4 / 780M): the VCN EFC advertises x=COSITED_EVEN only —
|
||||
// the canonical H.26x left-cosited siting — while our 2x2-average shader is midpoint. The
|
||||
// difference is a half-pel chroma-x phase, imperceptible (and EFC's is arguably the more
|
||||
// correct one since nothing in our bitstream signals siting). Accept either bit per axis
|
||||
// and choose the closest to the shader's math: midpoint if offered, else cosited-even.
|
||||
let pick = |offered: u32| -> Option<u32> {
|
||||
if offered & vrgb::CHROMA_OFFSET_MIDPOINT != 0 {
|
||||
Some(vrgb::CHROMA_OFFSET_MIDPOINT)
|
||||
} else if offered & vrgb::CHROMA_OFFSET_COSITED_EVEN != 0 {
|
||||
Some(vrgb::CHROMA_OFFSET_COSITED_EVEN)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if rgb_caps.rgb_models & vrgb::MODEL_YCBCR_709 == 0
|
||||
|| rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0
|
||||
{
|
||||
return Err("no-709-narrow");
|
||||
}
|
||||
let (Some(x_offset), Some(y_offset)) = (
|
||||
pick(rgb_caps.x_chroma_offsets),
|
||||
pick(rgb_caps.y_chroma_offsets),
|
||||
) else {
|
||||
return Err("no-chroma-siting");
|
||||
};
|
||||
// 4. The encode-src format set under this profile must offer BGRA with DRM-modifier tiling —
|
||||
// the capture hands LINEAR BGRx dmabufs (fourcc XR24), which import as B8G8R8A8_UNORM.
|
||||
let profile_arr = [profile];
|
||||
let plist = vk::VideoProfileListInfoKHR::default().profiles(&profile_arr);
|
||||
let mut fmt_info = vk::PhysicalDeviceVideoFormatInfoKHR::default()
|
||||
.image_usage(vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR);
|
||||
fmt_info.p_next = &plist as *const _ as *const c_void;
|
||||
let get_fmt = vq_inst.fp().get_physical_device_video_format_properties_khr;
|
||||
let mut count = 0u32;
|
||||
let r = get_fmt(pd, &fmt_info, &mut count, std::ptr::null_mut());
|
||||
if r != vk::Result::SUCCESS || count == 0 {
|
||||
return Err("no-rgb-format");
|
||||
}
|
||||
let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize];
|
||||
let r = get_fmt(pd, &fmt_info, &mut count, props.as_mut_ptr());
|
||||
if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE {
|
||||
return Err("no-rgb-format");
|
||||
}
|
||||
if !props[..count as usize].iter().any(|p| {
|
||||
p.format == vk::Format::B8G8R8A8_UNORM
|
||||
&& p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT
|
||||
}) {
|
||||
return Err("no-bgra-modifier-tiling");
|
||||
}
|
||||
Ok((x_offset, y_offset))
|
||||
}
|
||||
|
||||
pub(super) unsafe fn make_video_image(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
fmt: vk::Format,
|
||||
w: u32,
|
||||
h: u32,
|
||||
layers: u32,
|
||||
usage: vk::ImageUsageFlags,
|
||||
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||
concurrent: &[u32],
|
||||
) -> Result<(vk::Image, vk::DeviceMemory)> {
|
||||
let mut ci = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.extent(vk::Extent3D {
|
||||
width: w,
|
||||
height: h,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(layers)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(usage)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.push_next(profile_list);
|
||||
if concurrent.len() >= 2 {
|
||||
ci = ci
|
||||
.sharing_mode(vk::SharingMode::CONCURRENT)
|
||||
.queue_family_indices(concurrent);
|
||||
} else {
|
||||
ci = ci.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
let req = device.get_image_memory_requirements(img);
|
||||
// Unwind on failure: callers (the open path) only ever see the completed pair.
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
mp,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
)),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok((img, mem))
|
||||
}
|
||||
|
||||
/// Build one in-flight frame's private resources: NV12 encode-src, Y/UV CSC scratch, its CSC
|
||||
/// descriptor set (Y/UV bound now, RGB per use), the bitstream buffer + feedback query, and the
|
||||
/// per-frame command buffers + sync. `profile_list`/`profile` are borrowed only during creation.
|
||||
///
|
||||
/// Builds in place into `f` — a [`Frame::default`] the caller has already parked in its
|
||||
/// [`VkTeardown`] guard — so every handle is owned by the unwind the moment it exists and a
|
||||
/// mid-build failure leaks nothing.
|
||||
pub(super) unsafe fn make_frame(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
w: u32,
|
||||
h: u32,
|
||||
fams: &[u32],
|
||||
profile: &vk::VideoProfileInfoKHR,
|
||||
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||
csc_dsl: vk::DescriptorSetLayout,
|
||||
csc_pool: vk::DescriptorPool,
|
||||
cmd_pool: vk::CommandPool,
|
||||
compute_pool: vk::CommandPool,
|
||||
bs_size: u64,
|
||||
sampler: vk::Sampler,
|
||||
with_ts: bool,
|
||||
csc: bool,
|
||||
pad_fmt: Option<vk::Format>,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
|
||||
f.cursor_serial = u64::MAX;
|
||||
// Padded-copy staging (unaligned-mode RGB-direct or native NV12): an aligned encode-src in
|
||||
// the session's picture format, filled by a transfer blit each frame — concurrent compute
|
||||
// (copy) + encode (source read). TRANSFER_SRC because the width-padding pass self-copies the
|
||||
// staging image's own last visible column (see `record_pad_blit`).
|
||||
if let Some(fmt) = pad_fmt {
|
||||
(f.pad_img, f.pad_mem) = make_video_image(
|
||||
device,
|
||||
mem_props,
|
||||
fmt,
|
||||
w,
|
||||
h,
|
||||
1,
|
||||
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR
|
||||
| vk::ImageUsageFlags::TRANSFER_DST
|
||||
| vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
profile_list,
|
||||
fams,
|
||||
)?;
|
||||
f.pad_view = make_view(device, f.pad_img, fmt, 0)?;
|
||||
}
|
||||
// RGB-direct sessions never touch the CSC pipeline: no NV12 encode-src, no Y/UV scratch, no
|
||||
// cursor overlay, no descriptor set — the encode source is the imported RGB itself (or the
|
||||
// CPU staging image, built lazily). Their Frame keeps the null handles (teardown-safe).
|
||||
if csc {
|
||||
make_frame_csc(
|
||||
device,
|
||||
mem_props,
|
||||
w,
|
||||
h,
|
||||
fams,
|
||||
profile_list,
|
||||
csc_dsl,
|
||||
csc_pool,
|
||||
sampler,
|
||||
f,
|
||||
)?;
|
||||
}
|
||||
make_frame_common(
|
||||
device,
|
||||
mem_props,
|
||||
profile,
|
||||
profile_list,
|
||||
cmd_pool,
|
||||
compute_pool,
|
||||
bs_size,
|
||||
with_ts,
|
||||
f,
|
||||
)
|
||||
}
|
||||
|
||||
/// The CSC-only half of [`make_frame`]: NV12 encode-src + Y/UV scratch + cursor + descriptors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn make_frame_csc(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
w: u32,
|
||||
h: u32,
|
||||
fams: &[u32],
|
||||
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||
csc_dsl: vk::DescriptorSetLayout,
|
||||
csc_pool: vk::DescriptorPool,
|
||||
sampler: vk::Sampler,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
|
||||
(f.nv12_src, f.nv12_mem) = make_video_image(
|
||||
device,
|
||||
mem_props,
|
||||
NV12,
|
||||
w,
|
||||
h,
|
||||
1,
|
||||
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST,
|
||||
profile_list,
|
||||
fams,
|
||||
)?;
|
||||
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?;
|
||||
// CSC scratch (Y R8 full-res, UV RG8 half-res).
|
||||
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8_UNORM,
|
||||
w,
|
||||
h,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
)?;
|
||||
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8G8_UNORM,
|
||||
w / 2,
|
||||
h / 2,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
)?;
|
||||
// Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (cursor-as-metadata). The
|
||||
// view/descriptor is static (bound at binding 3 below); only the image *content* changes, and
|
||||
// only when the pointer bitmap does — see `prep_cursor`.
|
||||
(f.cursor_img, f.cursor_mem, f.cursor_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8G8B8A8_UNORM,
|
||||
CURSOR_MAX,
|
||||
CURSOR_MAX,
|
||||
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
|
||||
)?;
|
||||
f.cursor_stage = device.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
|
||||
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
|
||||
None,
|
||||
)?;
|
||||
let cs_req = device.get_buffer_memory_requirements(f.cursor_stage);
|
||||
f.cursor_stage_mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(cs_req.size)
|
||||
.memory_type_index(find_mem(
|
||||
mem_props,
|
||||
cs_req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_buffer_memory(f.cursor_stage, f.cursor_stage_mem, 0)?;
|
||||
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use; binding 3
|
||||
// (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped).
|
||||
let dsls = [csc_dsl];
|
||||
f.csc_set = device.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(csc_pool)
|
||||
.set_layouts(&dsls),
|
||||
)?[0];
|
||||
let y_info = [vk::DescriptorImageInfo::default()
|
||||
.image_view(f.y_view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)];
|
||||
let uv_info = [vk::DescriptorImageInfo::default()
|
||||
.image_view(f.uv_view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)];
|
||||
let cur_info = [vk::DescriptorImageInfo::default()
|
||||
.sampler(sampler)
|
||||
.image_view(f.cursor_view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
|
||||
device.update_descriptor_sets(
|
||||
&[
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||
.image_info(&y_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(2)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||
.image_info(&uv_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(3)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&cur_info),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mode-independent half of [`make_frame`]: bitstream buffer (+ persistent map), feedback
|
||||
/// query, optional timestamp pool, command buffers and sync objects.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn make_frame_common(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
profile: &vk::VideoProfileInfoKHR,
|
||||
profile_list: &mut vk::VideoProfileListInfoKHR,
|
||||
cmd_pool: vk::CommandPool,
|
||||
compute_pool: vk::CommandPool,
|
||||
bs_size: u64,
|
||||
with_ts: bool,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// Bitstream buffer + feedback query.
|
||||
f.bs_buf = device.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(bs_size)
|
||||
.usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR)
|
||||
.push_next(profile_list),
|
||||
None,
|
||||
)?;
|
||||
let bs_req = device.get_buffer_memory_requirements(f.bs_buf);
|
||||
f.bs_mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(bs_req.size)
|
||||
.memory_type_index(find_mem(
|
||||
mem_props,
|
||||
bs_req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_buffer_memory(f.bs_buf, f.bs_mem, 0)?;
|
||||
// Map once for the slot's lifetime — read_slot copies AUs straight out of this (coherent
|
||||
// memory, no per-frame map/unmap); vkFreeMemory implicitly unmaps at teardown.
|
||||
f.bs_ptr = BsPtr(
|
||||
device.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8,
|
||||
);
|
||||
// PUNKTFUNK_PERF: a 2-slot timestamp pool bracketing this slot's compute batch (CSC split).
|
||||
if with_ts {
|
||||
f.ts_pool = device.create_query_pool(
|
||||
&vk::QueryPoolCreateInfo::default()
|
||||
.query_type(vk::QueryType::TIMESTAMP)
|
||||
.query_count(2),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags(
|
||||
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
|
||||
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
|
||||
);
|
||||
fb_ci.p_next = profile as *const _ as *const c_void;
|
||||
let mut query_ci = vk::QueryPoolCreateInfo::default()
|
||||
.query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR)
|
||||
.query_count(1);
|
||||
query_ci.p_next = &fb_ci as *const _ as *const c_void;
|
||||
f.query_pool = device.create_query_pool(&query_ci, None)?;
|
||||
// Command buffers + per-frame sync.
|
||||
f.cmd = device.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(cmd_pool)
|
||||
.command_buffer_count(1),
|
||||
)?[0];
|
||||
f.compute_cmd = device.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(compute_pool)
|
||||
.command_buffer_count(1),
|
||||
)?[0];
|
||||
f.csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
|
||||
f.fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the
|
||||
/// session-parameters object + the encoded header bytes (VPS+SPS+PPS NALs) for keyframes.
|
||||
pub(super) unsafe fn build_parameters_h265(
|
||||
device: &ash::Device,
|
||||
vq_dev: &ash::khr::video_queue::Device,
|
||||
venc_dev: &ash::khr::video_encode_queue::Device,
|
||||
session: vk::VideoSessionKHR,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rw: u32,
|
||||
rh: u32,
|
||||
quality_level: u32,
|
||||
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
|
||||
use ash::vk::native as hh;
|
||||
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
|
||||
ptl.flags.set_general_progressive_source_flag(1);
|
||||
ptl.flags.set_general_frame_only_constraint_flag(1);
|
||||
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN;
|
||||
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
|
||||
|
||||
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
|
||||
dpbm.max_dec_pic_buffering_minus1[0] = (DPB_SLOTS - 1) as u8;
|
||||
dpbm.max_num_reorder_pics[0] = 0;
|
||||
dpbm.max_latency_increase_plus1[0] = 0;
|
||||
|
||||
let mut vps: hh::StdVideoH265VideoParameterSet = std::mem::zeroed();
|
||||
vps.flags.set_vps_temporal_id_nesting_flag(1);
|
||||
vps.flags.set_vps_sub_layer_ordering_info_present_flag(1);
|
||||
vps.pDecPicBufMgr = &dpbm;
|
||||
vps.pProfileTierLevel = &ptl;
|
||||
|
||||
let mut sps: hh::StdVideoH265SequenceParameterSet = std::mem::zeroed();
|
||||
sps.flags.set_sps_temporal_id_nesting_flag(1);
|
||||
sps.flags.set_sps_sub_layer_ordering_info_present_flag(1);
|
||||
sps.chroma_format_idc = hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_420;
|
||||
sps.pic_width_in_luma_samples = w;
|
||||
sps.pic_height_in_luma_samples = h;
|
||||
sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
|
||||
sps.log2_diff_max_min_luma_coding_block_size = 3;
|
||||
sps.log2_diff_max_min_luma_transform_block_size = 3;
|
||||
sps.max_transform_hierarchy_depth_inter = 4;
|
||||
sps.max_transform_hierarchy_depth_intra = 4;
|
||||
sps.pProfileTierLevel = &ptl;
|
||||
sps.pDecPicBufMgr = &dpbm;
|
||||
if w != rw || h != rh {
|
||||
sps.flags.set_conformance_window_flag(1);
|
||||
sps.conf_win_right_offset = (w - rw) / 2; // 4:2:0 SubWidthC = 2
|
||||
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
|
||||
}
|
||||
|
||||
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
|
||||
pps.flags.set_cu_qp_delta_enabled_flag(1);
|
||||
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
|
||||
|
||||
let vps_arr = [vps];
|
||||
let sps_arr = [sps];
|
||||
let pps_arr = [pps];
|
||||
let add = vk::VideoEncodeH265SessionParametersAddInfoKHR::default()
|
||||
.std_vp_ss(&vps_arr)
|
||||
.std_sp_ss(&sps_arr)
|
||||
.std_pp_ss(&pps_arr);
|
||||
let mut h265_ci = vk::VideoEncodeH265SessionParametersCreateInfoKHR::default()
|
||||
.max_std_vps_count(1)
|
||||
.max_std_sps_count(1)
|
||||
.max_std_pps_count(1)
|
||||
.parameters_add_info(&add);
|
||||
// Bake the session's quality level into the parameters object — the spec requires it to match
|
||||
// the level the first frame's ENCODE_QUALITY_LEVEL control installs.
|
||||
let mut q_info = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(quality_level);
|
||||
let ci = vk::VideoSessionParametersCreateInfoKHR::default()
|
||||
.video_session(session)
|
||||
.push_next(&mut h265_ci)
|
||||
.push_next(&mut q_info);
|
||||
let mut params = vk::VideoSessionParametersKHR::null();
|
||||
let r = (vq_dev.fp().create_video_session_parameters_khr)(
|
||||
device.handle(),
|
||||
&ci,
|
||||
std::ptr::null(),
|
||||
&mut params,
|
||||
);
|
||||
if r != vk::Result::SUCCESS {
|
||||
bail!("create_video_session_parameters: {r:?}");
|
||||
}
|
||||
|
||||
let mut get_h265 = vk::VideoEncodeH265SessionParametersGetInfoKHR::default()
|
||||
.write_std_vps(true)
|
||||
.write_std_sps(true)
|
||||
.write_std_pps(true)
|
||||
.std_vps_id(0)
|
||||
.std_sps_id(0)
|
||||
.std_pps_id(0);
|
||||
let get = vk::VideoEncodeSessionParametersGetInfoKHR::default()
|
||||
.video_session_parameters(params)
|
||||
.push_next(&mut get_h265);
|
||||
let get_fn = venc_dev.fp().get_encoded_video_session_parameters_khr;
|
||||
let mut fb = vk::VideoEncodeSessionParametersFeedbackInfoKHR::default();
|
||||
let mut size: usize = 0;
|
||||
let r = get_fn(
|
||||
device.handle(),
|
||||
&get,
|
||||
&mut fb,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
if r != vk::Result::SUCCESS {
|
||||
// `params` is live but not yet the caller's guard's to unwind — destroy before bailing.
|
||||
(vq_dev.fp().destroy_video_session_parameters_khr)(
|
||||
device.handle(),
|
||||
params,
|
||||
std::ptr::null(),
|
||||
);
|
||||
bail!("get header size: {r:?}");
|
||||
}
|
||||
let mut buf = vec![0u8; size];
|
||||
let r = get_fn(
|
||||
device.handle(),
|
||||
&get,
|
||||
&mut fb,
|
||||
&mut size,
|
||||
buf.as_mut_ptr() as *mut c_void,
|
||||
);
|
||||
if r != vk::Result::SUCCESS {
|
||||
(vq_dev.fp().destroy_video_session_parameters_khr)(
|
||||
device.handle(),
|
||||
params,
|
||||
std::ptr::null(),
|
||||
);
|
||||
bail!("get header bytes: {r:?}");
|
||||
}
|
||||
buf.truncate(size);
|
||||
Ok((params, buf))
|
||||
}
|
||||
|
||||
/// AV1 low-overhead OBU bit-writer (MSB-first), used to hand-pack the sequence-header OBU that
|
||||
/// Vulkan AV1 encode (unlike H26x) never emits itself.
|
||||
struct Av1BitWriter {
|
||||
buf: Vec<u8>,
|
||||
cur: u8,
|
||||
fill: u8,
|
||||
}
|
||||
impl Av1BitWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::new(),
|
||||
cur: 0,
|
||||
fill: 0,
|
||||
}
|
||||
}
|
||||
fn bit(&mut self, b: u32) {
|
||||
self.cur = (self.cur << 1) | (b as u8 & 1);
|
||||
self.fill += 1;
|
||||
if self.fill == 8 {
|
||||
self.buf.push(self.cur);
|
||||
self.cur = 0;
|
||||
self.fill = 0;
|
||||
}
|
||||
}
|
||||
fn put(&mut self, val: u32, bits: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
self.bit((val >> i) & 1);
|
||||
}
|
||||
}
|
||||
/// Flush, zero-padding the final partial byte (OBU size field delimits the payload).
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.fill > 0 {
|
||||
self.cur <<= 8 - self.fill;
|
||||
self.buf.push(self.cur);
|
||||
}
|
||||
self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// AV1 leb128 (little-endian base-128) encoding of an OBU size.
|
||||
fn leb128(mut v: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let mut byte = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
out.push(byte);
|
||||
if v == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bit-pack a `sequence_header_obu` (AV1 spec §5.5) into a size-delimited OBU. The field values here
|
||||
/// MUST mirror the `StdVideoAV1SequenceHeader` handed to the driver in `build_parameters_av1` so the
|
||||
/// driver-emitted frame OBUs parse against this header. Single operating point, 8-bit 4:2:0,
|
||||
/// order-hint on, CDEF+restoration+filter-intra allowed, everything exotic (compound/warp/superres)
|
||||
/// disabled — the profile our single-reference P-frame encoder actually uses.
|
||||
fn av1_sequence_header_obu(
|
||||
sb128: bool,
|
||||
fwb: u32,
|
||||
fhb: u32,
|
||||
max_w_m1: u32,
|
||||
max_h_m1: u32,
|
||||
order_hint_bits_minus_1: u32,
|
||||
seq_level_idx: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut w = Av1BitWriter::new();
|
||||
w.put(0, 3); // seq_profile = MAIN
|
||||
w.bit(0); // still_picture
|
||||
w.bit(0); // reduced_still_picture_header
|
||||
w.bit(0); // timing_info_present_flag
|
||||
w.bit(0); // initial_display_delay_present_flag
|
||||
w.put(0, 5); // operating_points_cnt_minus_1 = 0
|
||||
w.put(0, 12); // operating_point_idc[0]
|
||||
w.put(seq_level_idx, 5); // seq_level_idx[0]
|
||||
if seq_level_idx > 7 {
|
||||
w.bit(0); // seq_tier[0] = 0
|
||||
}
|
||||
w.put(fwb, 4); // frame_width_bits_minus_1
|
||||
w.put(fhb, 4); // frame_height_bits_minus_1
|
||||
w.put(max_w_m1, fwb + 1); // max_frame_width_minus_1
|
||||
w.put(max_h_m1, fhb + 1); // max_frame_height_minus_1
|
||||
w.bit(0); // frame_id_numbers_present_flag
|
||||
w.bit(sb128 as u32); // use_128x128_superblock
|
||||
w.bit(0); // enable_filter_intra
|
||||
w.bit(0); // enable_intra_edge_filter
|
||||
w.bit(0); // enable_interintra_compound
|
||||
w.bit(0); // enable_masked_compound
|
||||
w.bit(0); // enable_warped_motion
|
||||
w.bit(0); // enable_dual_filter
|
||||
w.bit(1); // enable_order_hint
|
||||
w.bit(0); // enable_jnt_comp
|
||||
w.bit(0); // enable_ref_frame_mvs
|
||||
w.bit(1); // seq_choose_screen_content_tools -> seq_force_screen_content_tools = SELECT
|
||||
w.bit(1); // seq_choose_integer_mv -> seq_force_integer_mv = SELECT
|
||||
w.put(order_hint_bits_minus_1, 3); // order_hint_bits_minus_1
|
||||
w.bit(0); // enable_superres
|
||||
w.bit(0); // enable_cdef
|
||||
w.bit(0); // enable_restoration
|
||||
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range
|
||||
w.bit(0); // high_bitdepth
|
||||
w.bit(0); // mono_chrome
|
||||
w.bit(0); // color_description_present_flag
|
||||
w.bit(0); // color_range (studio/limited)
|
||||
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
|
||||
w.bit(0); // separate_uv_delta_q
|
||||
w.bit(0); // film_grain_params_present
|
||||
|
||||
// trailing_bits(): a stop `1` bit then zero-pad to a byte (the size field delimits the OBU, but
|
||||
// the parser still requires the trailing_one_bit — dav1d/cbs reject a plain zero pad).
|
||||
w.bit(1);
|
||||
let payload = w.finish();
|
||||
let mut obu = vec![0x0au8]; // obu_header: type=OBU_SEQUENCE_HEADER(1), has_size_field=1
|
||||
obu.extend_from_slice(&leb128(payload.len() as u64));
|
||||
obu.extend_from_slice(&payload);
|
||||
obu
|
||||
}
|
||||
|
||||
/// AV1 session parameters + header framing. Vulkan AV1 encode emits only the per-frame OBU, so we
|
||||
/// return the app-owned prefixes: a temporal-delimiter OBU that opens every temporal unit
|
||||
/// (`frame_prefix`), and TD + the bit-packed sequence-header OBU for keyframes (`header`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) unsafe fn build_parameters_av1(
|
||||
device: &ash::Device,
|
||||
vq_dev: &ash::khr::video_queue::Device,
|
||||
session: vk::VideoSessionKHR,
|
||||
w: u32,
|
||||
h: u32,
|
||||
_rw: u32,
|
||||
_rh: u32,
|
||||
max_level: ash::vk::native::StdVideoAV1Level,
|
||||
sb128: bool,
|
||||
quality_level: u32,
|
||||
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>, Vec<u8>)> {
|
||||
use crate::vk_av1_encode as av1;
|
||||
use ash::vk::native as hh;
|
||||
|
||||
let fwb = 31 - w.leading_zeros(); // av_log2(w): enough bits for max_frame_width_minus_1 = w-1
|
||||
let fhb = 31 - h.leading_zeros();
|
||||
let order_hint_bits_minus_1: u32 = 7; // OrderHintBits = 8
|
||||
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
|
||||
|
||||
// ---- Std sequence header (must match the OBU packed below) ----
|
||||
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
|
||||
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0
|
||||
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
|
||||
cc.flags = cc_flags;
|
||||
cc.BitDepth = 8;
|
||||
cc.subsampling_x = 1;
|
||||
cc.subsampling_y = 1;
|
||||
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED;
|
||||
cc.transfer_characteristics =
|
||||
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
|
||||
cc.matrix_coefficients =
|
||||
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED;
|
||||
cc.chroma_sample_position =
|
||||
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
|
||||
|
||||
// Match FFmpeg's Vulkan AV1 encoder (proven on this RADV/VCN path): the ONLY coding tools
|
||||
// enabled are order-hint and (per caps) 128x128 superblocks. CDEF, loop restoration, filter-
|
||||
// intra, warped/compound motion, superres all OFF — enabling them made VCN emit frame-header
|
||||
// sections whose bit layout our sequence header didn't match, desyncing every inter frame.
|
||||
let mut sh_flags: hh::StdVideoAV1SequenceHeaderFlags = std::mem::zeroed();
|
||||
if sb128 {
|
||||
sh_flags.set_use_128x128_superblock(1);
|
||||
}
|
||||
sh_flags.set_enable_order_hint(1);
|
||||
let mut sh: hh::StdVideoAV1SequenceHeader = std::mem::zeroed();
|
||||
sh.flags = sh_flags;
|
||||
sh.seq_profile = hh::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN;
|
||||
sh.frame_width_bits_minus_1 = fwb as u8;
|
||||
sh.frame_height_bits_minus_1 = fhb as u8;
|
||||
sh.max_frame_width_minus_1 = (w - 1) as u16;
|
||||
sh.max_frame_height_minus_1 = (h - 1) as u16;
|
||||
sh.order_hint_bits_minus_1 = order_hint_bits_minus_1 as u8;
|
||||
sh.seq_force_integer_mv = 2; // SELECT
|
||||
sh.seq_force_screen_content_tools = 2; // SELECT
|
||||
sh.pColorConfig = &cc;
|
||||
|
||||
// ---- single operating point conveying the level/tier the driver targets ----
|
||||
let op = av1::StdVideoEncodeAV1OperatingPointInfo {
|
||||
flags: std::mem::zeroed(),
|
||||
operating_point_idc: 0,
|
||||
seq_level_idx: seq_level_idx as u8,
|
||||
seq_tier: 0,
|
||||
decoder_buffer_delay: 0,
|
||||
encoder_buffer_delay: 0,
|
||||
initial_display_delay_minus_1: 0,
|
||||
};
|
||||
let ops = [op];
|
||||
let av1_spci = av1::VideoEncodeAV1SessionParametersCreateInfoKHR {
|
||||
s_type: av1::stype(av1::ST_SESSION_PARAMETERS_CREATE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
p_std_sequence_header: &sh,
|
||||
p_std_decoder_model_info: std::ptr::null(),
|
||||
std_operating_point_count: 1,
|
||||
p_std_operating_points: ops.as_ptr() as *const c_void,
|
||||
};
|
||||
// Bake the session's quality level into the parameters object (must match the level the first
|
||||
// frame's ENCODE_QUALITY_LEVEL control installs); chained raw ahead of the vendored AV1 struct.
|
||||
let mut q_info = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(quality_level);
|
||||
q_info.p_next = &av1_spci as *const _ as *const c_void;
|
||||
let mut ci = vk::VideoSessionParametersCreateInfoKHR::default().video_session(session);
|
||||
ci.p_next = &q_info as *const _ as *const c_void;
|
||||
let mut params = vk::VideoSessionParametersKHR::null();
|
||||
let r = (vq_dev.fp().create_video_session_parameters_khr)(
|
||||
device.handle(),
|
||||
&ci,
|
||||
std::ptr::null(),
|
||||
&mut params,
|
||||
);
|
||||
if r != vk::Result::SUCCESS {
|
||||
bail!("create_video_session_parameters (av1): {r:?}");
|
||||
}
|
||||
|
||||
// ---- header framing: TD every temporal unit; TD + seq-header OBU on keyframes ----
|
||||
let td = vec![0x12u8, 0x00]; // temporal_delimiter OBU (type=2, size=0)
|
||||
let seq_obu = av1_sequence_header_obu(
|
||||
sb128,
|
||||
fwb,
|
||||
fhb,
|
||||
w - 1,
|
||||
h - 1,
|
||||
order_hint_bits_minus_1,
|
||||
seq_level_idx,
|
||||
);
|
||||
let mut keyframe_prefix = td.clone();
|
||||
keyframe_prefix.extend_from_slice(&seq_obu);
|
||||
Ok((params, keyframe_prefix, td))
|
||||
}
|
||||
@@ -54,6 +54,59 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a CPU RGB payload for Vulkan upload. The packed 24-bpp `Rgb`/`Bgr` the PipeWire
|
||||
/// capturer can negotiate are expanded 3→4 into `scratch` (kept by the caller across frames — no
|
||||
/// per-frame allocation) with the pad byte = 0xFF; refusing them instead used to kill a session
|
||||
/// at its first frame (WP5.4). No packed 24-bpp VkFormat is reliably uploadable/sampleable on
|
||||
/// target GPUs, and this path is CPU-sourced by definition, so one cheap expand pass serves it
|
||||
/// (the same call NVENC answers with its swscale 3→4 expand, WP1.4).
|
||||
///
|
||||
/// `bgra_target = false` (the CSC paths): channel order is preserved — the sampler reads through
|
||||
/// the matching view format, so any 4-bpp order works and 4-bpp inputs pass through borrowed.
|
||||
/// `bgra_target = true` (the RGB-direct encode source): the output byte order is forced to
|
||||
/// B,G,R,X, because the video session's `pictureFormat` is `B8G8R8A8_UNORM` and
|
||||
/// VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 requires the source image to match it — an
|
||||
/// R-first source (`Rgbx`/`Rgba`/`Rgb`) is channel-swapped during the same pass. (Caught live on
|
||||
/// RADV by `vulkan_smoke_rgb_cpu24`; the mismatch predates the 24-bpp support for `Rgbx` CPU
|
||||
/// sources.)
|
||||
///
|
||||
/// Payloads are tightly packed with no row padding (`FramePayload::Cpu`'s contract), so the
|
||||
/// conversion is row-agnostic; a truncated source yields a truncated output, which the upload
|
||||
/// paths already bound-check exactly as they did the raw bytes.
|
||||
pub(crate) fn normalize_cpu_rgb<'a>(
|
||||
fmt: PixelFormat,
|
||||
bytes: &'a [u8],
|
||||
scratch: &'a mut Vec<u8>,
|
||||
bgra_target: bool,
|
||||
) -> (PixelFormat, &'a [u8]) {
|
||||
// Per-pixel source layout: bytes-per-pixel + where R, G, B sit in each pixel.
|
||||
let (bpp, r, g, b) = match fmt {
|
||||
PixelFormat::Rgb => (3usize, 0usize, 1usize, 2usize),
|
||||
PixelFormat::Bgr => (3, 2, 1, 0),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (4, 0, 1, 2),
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (4, 2, 1, 0),
|
||||
_ => return (fmt, bytes),
|
||||
};
|
||||
if bpp == 4 && (!bgra_target || b == 0) {
|
||||
return (fmt, bytes); // 4-bpp in an acceptable order: borrow untouched
|
||||
}
|
||||
let px = bytes.len() / bpp;
|
||||
scratch.clear();
|
||||
scratch.resize(px * 4, 0xFF);
|
||||
let (dr, dg, db) = if bgra_target { (2, 1, 0) } else { (r, g, b) };
|
||||
for (dst, src) in scratch.chunks_exact_mut(4).zip(bytes.chunks_exact(bpp)) {
|
||||
dst[dr] = src[r];
|
||||
dst[dg] = src[g];
|
||||
dst[db] = src[b];
|
||||
}
|
||||
let out_fmt = if bgra_target || b == 0 {
|
||||
PixelFormat::Bgrx
|
||||
} else {
|
||||
PixelFormat::Rgbx
|
||||
};
|
||||
(out_fmt, scratch.as_slice())
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn make_view(
|
||||
device: &ash::Device,
|
||||
image: vk::Image,
|
||||
@@ -70,6 +123,21 @@ pub(crate) unsafe fn make_view(
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Whether a failed dmabuf import should count toward pf-zerocopy's raw-dmabuf degrade latch
|
||||
/// (`note_raw_dmabuf_import_failure` — 3 consecutive failures flip capture to CPU delivery for
|
||||
/// the process). Deterministic refusals (unsupported fourcc, the driver rejecting the buffer)
|
||||
/// must count — they repeat identically forever and the latch is their only recovery. Transient
|
||||
/// VRAM pressure must NOT: three tight allocation OOMs would otherwise permanently downgrade a
|
||||
/// working host to CPU capture.
|
||||
pub(crate) fn import_failure_feeds_latch(e: &anyhow::Error) -> bool {
|
||||
match e.downcast_ref::<vk::Result>() {
|
||||
Some(&r) => {
|
||||
r != vk::Result::ERROR_OUT_OF_DEVICE_MEMORY && r != vk::Result::ERROR_OUT_OF_HOST_MEMORY
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys all
|
||||
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
|
||||
pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
@@ -108,9 +176,15 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
use anyhow::Context;
|
||||
use std::os::fd::IntoRawFd;
|
||||
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||
let fmt = fourcc_to_vk(d.fourcc)
|
||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||
// Dup the fd FIRST, and keep it OWNED: ownership transfers to Vulkan only on a SUCCESSFUL
|
||||
// `allocate_memory` (VK_KHR_external_memory_fd — from then on `vkFreeMemory` closes it), so
|
||||
// the release below sits in exactly that arm. Every earlier failure drops the `OwnedFd` for
|
||||
// a single clean close. An explicit `close` after a successful import would be a double
|
||||
// close — and a recycled fd number then clobbers an unrelated descriptor in this process.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?;
|
||||
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
||||
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
||||
d.offset as u64 + d.stride as u64 * ch as u64,
|
||||
@@ -155,14 +229,16 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
ci = ci.push_next(pl);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||
// Unwind discipline below mirrors `make_plain_image`: every failure destroys what this call
|
||||
// created (and ONLY that — the caller's `DmabufFrame` fd stays theirs).
|
||||
let fd_props = {
|
||||
let mut p = vk::MemoryFdPropertiesKHR::default();
|
||||
// Borrow-only query (no ownership transfer); an error leaves memory_type_bits = 0 and
|
||||
// the fallback below uses the image requirements alone.
|
||||
let _ = (ext_fd.fp().get_memory_fd_properties_khr)(
|
||||
device.handle(),
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup,
|
||||
dup.as_raw_fd(),
|
||||
&mut p,
|
||||
);
|
||||
p.memory_type_bits
|
||||
@@ -181,27 +257,87 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
|
||||
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(dup);
|
||||
let mem = device.allocate_memory(
|
||||
.fd(dup.as_raw_fd());
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ti)
|
||||
.push_next(&mut ded)
|
||||
.push_next(&mut import),
|
||||
None,
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = device.create_image_view(
|
||||
) {
|
||||
Ok(mem) => {
|
||||
// Success transferred fd ownership to the memory object — release, don't close.
|
||||
let _ = dup.into_raw_fd();
|
||||
mem
|
||||
}
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
return Err(e.into()); // `dup` drops here: the one close of the failed import's fd
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None); // closes the imported fd
|
||||
return Err(e.into());
|
||||
}
|
||||
let view = match device.create_image_view(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(img)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.subresource_range(color_range(0)),
|
||||
None,
|
||||
)?;
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
Ok((img, mem, view))
|
||||
}
|
||||
|
||||
/// Create + allocate + bind a host-visible/coherent buffer with `make_plain_image`'s unwind
|
||||
/// discipline: on any failure everything this call created is destroyed before returning, so
|
||||
/// callers can `?` freely. Both `ensure_cpu_rgb` staging twins open-coded this sequence and
|
||||
/// leaked the buffer (and then buffer+memory) on the allocate/bind failure arms.
|
||||
pub(crate) unsafe fn make_host_buffer(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
size: u64,
|
||||
usage: vk::BufferUsageFlags,
|
||||
) -> Result<(vk::Buffer, vk::DeviceMemory)> {
|
||||
let buf = device.create_buffer(
|
||||
&vk::BufferCreateInfo::default().size(size).usage(usage),
|
||||
None,
|
||||
)?;
|
||||
let req = device.get_buffer_memory_requirements(buf);
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
mp,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
device.destroy_buffer(buf, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_buffer_memory(buf, mem, 0) {
|
||||
device.destroy_buffer(buf, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok((buf, mem))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn make_plain_image(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -259,3 +395,80 @@ pub(crate) unsafe fn make_plain_image(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
|
||||
/// reorder, pad byte 0xFF, truncated tail pixels dropped (never overrun) — and 4-bpp inputs
|
||||
/// pass through borrowed untouched.
|
||||
#[test]
|
||||
fn normalize_cpu_rgb_expands_24bpp_and_borrows_4bpp() {
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5, 6], &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Rgbx);
|
||||
assert_eq!(b, &[1, 2, 3, 0xFF, 4, 5, 6, 0xFF]);
|
||||
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||
|
||||
// Truncated tail: 5 bytes = one whole pixel + a 2-byte remainder that must be dropped.
|
||||
let mut scratch = Vec::new();
|
||||
let (_, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5], &mut scratch, false);
|
||||
assert_eq!(b, &[1, 2, 3, 0xFF]);
|
||||
|
||||
// 4-bpp passthrough: borrowed, scratch untouched.
|
||||
let src = [10u8, 20, 30, 40];
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgrx, &src, &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||
assert!(scratch.is_empty());
|
||||
|
||||
// The 4-bpp mapping the expand lands on matches pixel_to_vk's existing table.
|
||||
assert_eq!(
|
||||
pixel_to_vk(PixelFormat::Rgbx),
|
||||
Some(vk::Format::R8G8B8A8_UNORM)
|
||||
);
|
||||
assert_eq!(
|
||||
pixel_to_vk(PixelFormat::Bgrx),
|
||||
Some(vk::Format::B8G8R8A8_UNORM)
|
||||
);
|
||||
}
|
||||
|
||||
/// RGB-direct mode (`bgra_target = true`): everything lands in B,G,R,X order because the
|
||||
/// video session's `pictureFormat` is `B8G8R8A8_UNORM` and the encode source must match it
|
||||
/// (VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 — caught live on RADV). B-first inputs pass
|
||||
/// through borrowed; R-first inputs are channel-swapped, 3-bpp and 4-bpp alike.
|
||||
#[test]
|
||||
fn normalize_cpu_rgb_forces_bgra_for_the_encode_source() {
|
||||
// Rgb (R,G,B) → B,G,R,X with the swap folded into the expand.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||
|
||||
// Bgr (B,G,R) → same order, expanded.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||
|
||||
// Rgbx: 4-bpp but R-first — swapped, alpha replaced by the 0xFF pad.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgbx, &[1, 2, 3, 4], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||
|
||||
// Bgrx/Bgra already match the session order: borrowed untouched.
|
||||
let src = [10u8, 20, 30, 40];
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgra, &src, &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgra);
|
||||
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||
assert!(scratch.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,3 +80,74 @@ pub struct VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
||||
pub fn stype(raw: i32) -> vk::StructureType {
|
||||
vk::StructureType::from_raw(raw)
|
||||
}
|
||||
|
||||
// ---------- ABI layout guard ----------
|
||||
//
|
||||
// These structs are hand-copied from the registry and handed to the driver through raw `p_next`
|
||||
// chains, so nothing in the type system relates them to the C definitions any more: an edit that
|
||||
// inserts, drops, widens or re-pads a field is not a compile error, it is the driver reading our
|
||||
// bytes at the wrong offsets. The assertions below are the missing compile error. They are `const`
|
||||
// rather than `#[cfg(test)]` (the shape `amf.rs` uses) so they hold in every build, including the
|
||||
// shipped one, and on any target this module compiles for.
|
||||
//
|
||||
// What they catch: a changed field width, an inserted or removed field, a changed array length, a
|
||||
// padding assumption that only holds on one target. What they CANNOT catch: swapping two fields of
|
||||
// the same type — offsets are unchanged. That case is only caught by reading the registry, so the
|
||||
// field order here was diffed against `vulkan_core.h` (Vulkan-Headers `main`, 2026-07-25) when
|
||||
// these assertions were written, along with every `ST_*` and flag-bit value above.
|
||||
//
|
||||
// Deliberately duplicated in `vk_av1_encode.rs` rather than shared: both modules exist to be
|
||||
// deleted wholesale once `ash` ships these bindings, and a shared helper would make deleting one
|
||||
// break the other.
|
||||
macro_rules! assert_abi_layout {
|
||||
($t:ty { size: $size:expr, align: $align:expr $(, $field:ident @ $off:expr)* $(,)? }) => {
|
||||
const _: () = {
|
||||
assert!(
|
||||
::core::mem::size_of::<$t>() == $size,
|
||||
concat!(stringify!($t), ": size does not match the C ABI")
|
||||
);
|
||||
assert!(
|
||||
::core::mem::align_of::<$t>() == $align,
|
||||
concat!(stringify!($t), ": alignment does not match the C ABI")
|
||||
);
|
||||
$(assert!(
|
||||
::core::mem::offset_of!($t, $field) == $off,
|
||||
concat!(stringify!($t), ".", stringify!($field), ": offset does not match the C ABI")
|
||||
);)*
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
assert_abi_layout!(PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
video_encode_rgb_conversion @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||
size: 32, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
rgb_models @ 16,
|
||||
rgb_ranges @ 20,
|
||||
x_chroma_offsets @ 24,
|
||||
y_chroma_offsets @ 28,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeProfileRgbConversionInfoVALVE {
|
||||
size: 24, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
perform_encode_rgb_conversion @ 16,
|
||||
});
|
||||
|
||||
assert_abi_layout!(VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
||||
size: 32, align: 8,
|
||||
s_type @ 0,
|
||||
p_next @ 8,
|
||||
rgb_model @ 16,
|
||||
rgb_range @ 20,
|
||||
x_chroma_offset @ 24,
|
||||
y_chroma_offset @ 28,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,12 +67,385 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and
|
||||
/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which
|
||||
/// logged and one didn't). Precedence:
|
||||
/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator
|
||||
/// override, always wins.
|
||||
/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240
|
||||
/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge
|
||||
/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was
|
||||
/// the "broken animations in HDR" cap at ~131 fps.
|
||||
/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below
|
||||
/// ~2112 px height, so 4K120 must be forced onto the second engine).
|
||||
/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates).
|
||||
///
|
||||
/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that
|
||||
/// rejects the chosen mode downgrades at open, not here.
|
||||
pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() {
|
||||
Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32,
|
||||
Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
_ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ => M::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode = mode,
|
||||
bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
mode
|
||||
}
|
||||
|
||||
/// One session config's identity for the process-lifetime bitrate-ceiling cache
|
||||
/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys
|
||||
/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma
|
||||
/// rate selects the level), depth/chroma (they select the profile) and the split mode the
|
||||
/// sessions ACTUALLY opened with (a split session budgets per engine).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct CeilingKey {
|
||||
/// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render
|
||||
/// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see
|
||||
/// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a
|
||||
/// wrong session.
|
||||
pub gpu: u64,
|
||||
pub codec: Codec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bit_depth: u8,
|
||||
pub chroma_444: bool,
|
||||
pub split_mode: u32,
|
||||
}
|
||||
|
||||
fn ceilings() -> &'static std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>> {
|
||||
static CEILINGS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CEILINGS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this
|
||||
/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value
|
||||
/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) —
|
||||
/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR
|
||||
/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight
|
||||
/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session
|
||||
/// churn per rebuild.
|
||||
pub(super) fn cached_ceiling(key: &CeilingKey) -> Option<u64> {
|
||||
ceilings().lock().unwrap().get(key).copied()
|
||||
}
|
||||
|
||||
/// Record the clamp search's discovered max accepted bitrate (bps) for `key`.
|
||||
pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
|
||||
ceilings().lock().unwrap().insert(key, bps);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
// These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins.
|
||||
|
||||
/// `encodeCodecConfig` is a C union, so the HEVC 4:4:4 arm must be codec-gated or it stamps
|
||||
/// `hevcConfig` bytes onto another codec's config. Before the gate this branch was reached on
|
||||
/// ANY codec with `chroma_444 && full_chroma_input` and stayed non-UB only because `lib.rs`
|
||||
/// degrades 4:4:4 for non-HEVC — a two-file invariant with nothing asserting it.
|
||||
///
|
||||
/// It also had to stop swallowing the per-codec bit-depth arm: this is an `if`/`else if`, so a
|
||||
/// non-HEVC 4:4:4 session used to take the HEVC branch and get NEITHER 4:4:4 nor its own 10-bit
|
||||
/// setup. AV1 asserts the depth it actually needs.
|
||||
fn low_latency_cfg(codec: Codec, chroma_444: bool, bit_depth: u8) -> LowLatencyConfig {
|
||||
LowLatencyConfig {
|
||||
codec,
|
||||
bitrate: 20_000_000,
|
||||
fps: 60,
|
||||
custom_vbv: false,
|
||||
chroma_444,
|
||||
full_chroma_input: true,
|
||||
bit_depth,
|
||||
av1_input_depth_minus8: 0,
|
||||
hdr: false,
|
||||
rfi_supported: false,
|
||||
slices: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_444_still_takes_the_frext_path() {
|
||||
// `NV_ENC_CONFIG` must NOT be `mem::zeroed` — `frameFieldMode`/`mvPrecision` are C enums
|
||||
// whose discriminants start at 1, so all-zero is not a valid value and Rust's own
|
||||
// zero-init check aborts the process. Production seeds it the same way, from `Default`
|
||||
// (then overwrites from the driver's preset).
|
||||
// SAFETY: `apply_low_latency_config` only writes into the caller's config (union writes
|
||||
// included) and makes no driver calls, so this is pure in-memory work.
|
||||
let cfg = unsafe {
|
||||
let mut cfg = nv::NV_ENC_CONFIG {
|
||||
version: nv::NV_ENC_CONFIG_VER,
|
||||
..Default::default()
|
||||
};
|
||||
apply_low_latency_config(&mut cfg, low_latency_cfg(Codec::H265, true, 10));
|
||||
cfg
|
||||
};
|
||||
assert_eq!(cfg.profileGUID, nv::NV_ENC_HEVC_PROFILE_FREXT_GUID);
|
||||
// SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
|
||||
unsafe {
|
||||
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3);
|
||||
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_never_takes_the_hevc_444_union_write() {
|
||||
// SAFETY: as above — pure in-memory config authoring, no driver involvement.
|
||||
let cfg = unsafe {
|
||||
let mut cfg = nv::NV_ENC_CONFIG {
|
||||
version: nv::NV_ENC_CONFIG_VER,
|
||||
..Default::default()
|
||||
};
|
||||
apply_low_latency_config(&mut cfg, low_latency_cfg(Codec::Av1, true, 10));
|
||||
cfg
|
||||
};
|
||||
// The HEVC FREXT profile GUID on an AV1 session is an INVALID_PARAM at open.
|
||||
assert_ne!(
|
||||
cfg.profileGUID,
|
||||
nv::NV_ENC_HEVC_PROFILE_FREXT_GUID,
|
||||
"4:4:4 on AV1 must not stamp the HEVC FREXT profile"
|
||||
);
|
||||
// ...and the AV1 arm must still have run, which the old if/else-if skipped entirely.
|
||||
// SAFETY: an AV1 session's union arm is `av1Config`.
|
||||
unsafe {
|
||||
assert_eq!(
|
||||
cfg.encodeCodecConfig.av1Config.pixelBitDepthMinus8(),
|
||||
2,
|
||||
"AV1 10-bit setup was swallowed by the HEVC 4:4:4 branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_forces_two_way_at_4k120() {
|
||||
// The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat
|
||||
// 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a
|
||||
// 4090 because AUTO never engages at 2160 px height.
|
||||
let four_k_120 = 3840u64 * 2160 * 120;
|
||||
assert_eq!(
|
||||
resolve_split_mode(8, four_k_120),
|
||||
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_leaves_1440p240_auto() {
|
||||
// 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in.
|
||||
let qhd_240 = 2560u64 * 1440 * 240;
|
||||
assert_eq!(
|
||||
resolve_split_mode(8, qhd_240),
|
||||
M::NV_ENC_SPLIT_AUTO_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_disabled_for_10bit_even_at_high_pixel_rate() {
|
||||
// The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2
|
||||
// vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm.
|
||||
let five_k_240 = 5120u64 * 1440 * 240;
|
||||
assert_eq!(
|
||||
resolve_split_mode(10, five_k_240),
|
||||
M::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceiling_cache_round_trips_and_keys_precisely() {
|
||||
let key = CeilingKey {
|
||||
gpu: 0xB0B0,
|
||||
codec: Codec::H265,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
fps: 120,
|
||||
bit_depth: 8,
|
||||
chroma_444: false,
|
||||
split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
};
|
||||
assert_eq!(cached_ceiling(&key), None);
|
||||
store_ceiling(key, 794_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(794_000_000));
|
||||
// Any config-identity change is a different ceiling — a miss, never a wrong clamp.
|
||||
assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None);
|
||||
assert_eq!(
|
||||
cached_ceiling(&CeilingKey {
|
||||
split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
..key
|
||||
}),
|
||||
None
|
||||
);
|
||||
// A re-search overwrites (the advisory-cache stale-entry path).
|
||||
store_ceiling(key, 620_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(620_000_000));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||
/// paths check against (`frame_idx - RFI_DPB` = the oldest frame still in the DPB).
|
||||
/// P-frame single-reference for low latency. Also the window [`plan_range_recovery`] checks against
|
||||
/// (`next_ts - RFI_DPB` = the oldest frame still in the DPB).
|
||||
pub(super) const RFI_DPB: u32 = 5;
|
||||
|
||||
/// One loss event's recovery decision for the timestamp-range RFI both direct-NVENC backends run
|
||||
/// (the range half of WP7.2's policy extraction; the slot half — AMF/QSV/Vulkan — is
|
||||
/// `crate::rfi`). The mechanism (the per-timestamp `nvEncInvalidateRefFrames` loop, the
|
||||
/// `last_rfi_range`/`pending_anchor` stores, the null-handle/`rfi_supported` gate) stays in each
|
||||
/// backend.
|
||||
pub(super) enum RangePlan {
|
||||
/// The last successful invalidation already covers this range — no new driver calls, no IDR.
|
||||
/// The caller must still RE-ARM its recovery anchor: the client re-asking means the previous
|
||||
/// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor.
|
||||
Covered,
|
||||
/// Invalidate `first..=last` (the CLAMPED range — this is also what the caller must record in
|
||||
/// `last_rfi_range` on success, exactly as the inline code stored the post-clamp values).
|
||||
Invalidate { first: i64, last: i64 },
|
||||
/// Recovery without an IDR is impossible (nonsense range, loss older than the DPB, or a range
|
||||
/// entirely in the future) — the caller returns `false` and its (coalesced) keyframe path
|
||||
/// recovers. Deliberately NOT paired with any state clearing: neither twin touches
|
||||
/// `pending_anchor` on decline (matching Vulkan's decline, opposite of AMF/QSV's
|
||||
/// `pending_force` clear — see `crate::rfi`'s module doc before "harmonizing").
|
||||
Decline,
|
||||
}
|
||||
|
||||
/// The range-RFI policy, extracted verbatim from the two backends' `invalidate_ref_frames` (they
|
||||
/// were hand-copied twins). Step order is load-bearing and pinned by tests:
|
||||
///
|
||||
/// 1. nonsense range (`first < 0 || first > last`) → [`RangePlan::Decline`];
|
||||
/// 2. covering-range dedup — checked with the UNCLAMPED `last`, BEFORE the DPB window, so a
|
||||
/// covered re-ask never touches the driver even when the range has since left the DPB;
|
||||
/// 3. DPB window: `first < next_ts - RFI_DPB` → Decline (a lost frame older than the DPB cannot
|
||||
/// be invalidated; the only correct recovery is an IDR);
|
||||
/// 4. clamp `last` to `next_ts - 1` (never invalidate a timestamp never assigned); an inverted
|
||||
/// range after the clamp (loss entirely in the future — a prediction desync) → Decline.
|
||||
///
|
||||
/// `next_ts` is the backend's `frame_idx`: the NEXT timestamp to assign, which `submit_indexed`
|
||||
/// pins to the wire frame index — so the client's lost-frame range maps 1:1 onto the timestamps
|
||||
/// the driver invalidates, across every rebuild/reset. Note `teardown()` clears `last_rfi_range`
|
||||
/// but NOT `frame_idx`, so a post-reset call legitimately sees a stale-high `next_ts` with a
|
||||
/// `None` range — the same view the inline code had.
|
||||
pub(super) fn plan_range_recovery(
|
||||
first: i64,
|
||||
last: i64,
|
||||
next_ts: i64,
|
||||
last_rfi_range: Option<(i64, i64)>,
|
||||
) -> RangePlan {
|
||||
if first < 0 || first > last {
|
||||
return RangePlan::Decline;
|
||||
}
|
||||
if let Some((pf, pl)) = last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
return RangePlan::Covered;
|
||||
}
|
||||
}
|
||||
let oldest_in_dpb = next_ts - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return RangePlan::Decline;
|
||||
}
|
||||
let last = last.min(next_ts - 1);
|
||||
if first > last {
|
||||
return RangePlan::Decline;
|
||||
}
|
||||
RangePlan::Invalidate { first, last }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod range_policy_tests {
|
||||
use super::{plan_range_recovery, RangePlan, RFI_DPB};
|
||||
|
||||
/// Convenience: the plan with no prior invalidation recorded.
|
||||
fn plan(first: i64, last: i64, next_ts: i64) -> RangePlan {
|
||||
plan_range_recovery(first, last, next_ts, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_ranges_decline() {
|
||||
assert!(matches!(plan(-1, 5, 100), RangePlan::Decline));
|
||||
assert!(matches!(plan(7, 5, 100), RangePlan::Decline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covering_range_dedups_partial_overlap_does_not() {
|
||||
let prior = Some((90i64, 95i64));
|
||||
// Exact cover and sub-range → Covered. This pins EXISTING behavior, including that a
|
||||
// covered range survives a forced IDR with zero driver calls (nothing clears
|
||||
// `last_rfi_range` on a keyframe) — a recorded fact, not an endorsement.
|
||||
assert!(matches!(
|
||||
plan_range_recovery(90, 95, 100, prior),
|
||||
RangePlan::Covered
|
||||
));
|
||||
assert!(matches!(
|
||||
plan_range_recovery(92, 94, 100, prior),
|
||||
RangePlan::Covered
|
||||
));
|
||||
// Partial overlap re-invalidates the FULL new range (next_ts = 98 keeps the window open:
|
||||
// oldest_in_dpb = 93; at next_ts = 100 the same range would age out and Decline instead).
|
||||
assert!(matches!(
|
||||
plan_range_recovery(93, 97, 98, prior),
|
||||
RangePlan::Invalidate {
|
||||
first: 93,
|
||||
last: 97
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// The covering check runs BEFORE the DPB window: a covered re-ask stays Covered (no driver
|
||||
/// calls needed) even when the range has since aged out of the DPB.
|
||||
#[test]
|
||||
fn covered_is_checked_before_the_dpb_window() {
|
||||
let prior = Some((10i64, 12i64));
|
||||
assert!(matches!(
|
||||
plan_range_recovery(10, 12, 100, prior),
|
||||
RangePlan::Covered
|
||||
));
|
||||
// ...whereas the same range with no prior invalidation is outside the window → Decline.
|
||||
assert!(matches!(plan(10, 12, 100), RangePlan::Decline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpb_window_boundary() {
|
||||
let next_ts = 100i64;
|
||||
let oldest = next_ts - RFI_DPB as i64; // 95: the oldest timestamp still in the DPB
|
||||
assert!(matches!(
|
||||
plan(oldest, oldest, next_ts),
|
||||
RangePlan::Invalidate { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
plan(oldest - 1, oldest, next_ts),
|
||||
RangePlan::Decline
|
||||
));
|
||||
}
|
||||
|
||||
/// `last` clamps to `next_ts - 1` (the newest encoded frame); the Invalidate carries the
|
||||
/// CLAMPED value — which is also what the caller records in `last_rfi_range`.
|
||||
#[test]
|
||||
fn clamps_to_newest_encoded() {
|
||||
assert!(matches!(
|
||||
plan(98, 150, 100),
|
||||
RangePlan::Invalidate {
|
||||
first: 98,
|
||||
last: 99
|
||||
}
|
||||
));
|
||||
// A range entirely in the future inverts under the clamp → Decline (prediction desync).
|
||||
assert!(matches!(plan(100, 150, 100), RangePlan::Decline));
|
||||
// Fresh session (`frame_idx == 0`): window passes (oldest = -5) but the clamp gives
|
||||
// last = -1 < first → Decline. The inline code behaved identically.
|
||||
assert!(matches!(plan(0, 3, 0), RangePlan::Decline));
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-session knobs both direct-NVENC backends feed [`apply_low_latency_config`]. `Copy` so the
|
||||
/// backend fills it from `self` at the call. The two input-format fields bridge the only real
|
||||
/// divergence between the CUDA and D3D11 paths (which surface formats can carry full chroma / 10-bit
|
||||
@@ -219,7 +592,27 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
|
||||
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
|
||||
// HEVC Main10 GUID onto an AV1 session is an INVALID_PARAM, so bit depth is set PER CODEC.
|
||||
if c.chroma_444 && c.full_chroma_input {
|
||||
// `encodeCodecConfig` is a C UNION, so the `hevcConfig` writes below are only meaningful on an
|
||||
// HEVC session — on an H.264 or AV1 one they reinterpret that codec's own config bytes. The
|
||||
// codec test is therefore load-bearing, not defensive: without it this branch was gated purely
|
||||
// on `chroma_444 && full_chroma_input` and stayed non-UB only because `lib.rs` degrades 4:4:4
|
||||
// for non-HEVC codecs. That was a two-file invariant with nothing asserting it, on the path
|
||||
// BOTH direct-NVENC backends take.
|
||||
//
|
||||
// Being a codec test also fixes a second, quieter bug in the same shape: this is an
|
||||
// `if`/`else if`, so a non-HEVC session that somehow arrived with `chroma_444` set took this
|
||||
// branch and skipped the per-codec bit-depth arm entirely — ending up with neither HEVC 4:4:4
|
||||
// (wrong for it) nor its own 10-bit configuration (simply missing). Non-HEVC now falls through
|
||||
// to the arm that knows what to do with it.
|
||||
let want_444 = c.chroma_444 && c.full_chroma_input;
|
||||
if want_444 && c.codec != Codec::H265 {
|
||||
tracing::warn!(
|
||||
codec = ?c.codec,
|
||||
"4:4:4 requested on a non-HEVC NVENC session — ignoring it (Range Extensions are \
|
||||
HEVC-only); the negotiator should have degraded this to 4:2:0 before the open"
|
||||
);
|
||||
}
|
||||
if want_444 && c.codec == Codec::H265 {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if c.bit_depth == 10 {
|
||||
|
||||
@@ -8,27 +8,77 @@
|
||||
//! means and what the operator should do, and folds that cause into the `anyhow::Error` at
|
||||
//! construction, so every downstream `{e:#}` log (the encode-recovery loop, session teardown) says
|
||||
//! the useful thing without extra plumbing.
|
||||
//!
|
||||
//! One status needs process state to explain honestly: the driver reports BOTH "your headers are
|
||||
//! newer than my kernel module" and "I can no longer hand this process a session" as
|
||||
//! `NV_ENC_ERR_INVALID_VERSION`. [`note_session_opened`] latches the fact that a session already
|
||||
//! opened here, which tells the two apart — see [`explain`].
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
/// Latched the first time `nvEncOpenEncodeSessionEx` succeeds in this process (the caps probe, a
|
||||
/// real session open, or the Windows availability probe — every one of them completes the
|
||||
/// userspace↔kernel-module handshake).
|
||||
///
|
||||
/// The load-time gate (`NvEncodeAPIGetMaxSupportedVersion`, both backends' `load_api`) can NOT
|
||||
/// serve this purpose: it is a pure userspace query, so the genuine "updated the driver, didn't
|
||||
/// reboot" skew sails through it and fails later, at the open. Only a session that actually opened
|
||||
/// proves the kernel module agreed.
|
||||
static SESSION_OPENED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Record that an NVENC session opened. Call right after every successful
|
||||
/// `open_encode_session_ex`, so [`explain`] can rule a version skew out for the rest of the
|
||||
/// process.
|
||||
pub(super) fn note_session_opened() {
|
||||
SESSION_OPENED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The two very different failures the driver reports as `NV_ENC_ERR_INVALID_VERSION`, split on
|
||||
/// whether a session has already opened here (`session_opened`). Pure, so both halves are testable
|
||||
/// without touching the process-wide latch.
|
||||
fn invalid_version(session_opened: bool) -> String {
|
||||
if session_opened {
|
||||
// Same status, opposite cause: a session ALREADY opened in this process, so the driver's
|
||||
// kernel module accepted this exact build's version word minutes ago. A version skew is
|
||||
// static — it cannot come and go — so "update the driver / reboot" is the wrong advice
|
||||
// here, and following it costs the operator a reboot per stream (2026-07 field report: one
|
||||
// stream works, the next fails at the caps probe, forever, until the PROCESS restarts).
|
||||
// What is left is per-process driver state: a resource the last session did not give back,
|
||||
// or a wedged/lost device. Say that, and point at the cheap fix.
|
||||
// Worded for ANY call (`explain` also serves `lock_bitstream`); `call_err` already names
|
||||
// the entry point ahead of this text, so it must not assume the session open.
|
||||
return "this process already opened an NVENC session successfully, so this is NOT a driver \
|
||||
version mismatch — that cannot come and go within a process, and a reboot is not \
|
||||
the fix. The NVIDIA driver state in THIS process is exhausted or wedged: restart \
|
||||
the Punktfunk host service to clear it, and please report this with the host log \
|
||||
so it can be fixed properly"
|
||||
.to_string();
|
||||
}
|
||||
// No session has ever opened here, so the version word really is in question. Either the
|
||||
// driver is genuinely older than our headers, or (the sneaky case) the userspace
|
||||
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
|
||||
// kernel module is older and rejects the session — the classic "updated the driver, didn't
|
||||
// reboot" skew. Both heal the same way.
|
||||
format!(
|
||||
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
|
||||
newer), or the userspace and kernel-module driver versions are mismatched — common right \
|
||||
after a driver update without a reboot. Update the NVIDIA driver, or reboot if you just \
|
||||
updated it (a host restart is the usual fix).",
|
||||
nv::NVENCAPI_MAJOR_VERSION,
|
||||
nv::NVENCAPI_MINOR_VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
/// A one-line, operator-actionable cause for an NVENC status. Does not repeat the raw code —
|
||||
/// callers print that alongside (see [`call_err`]). Public for the few sites that build a
|
||||
/// `String`/`format!` error instead of an `anyhow::Error`.
|
||||
pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
match status {
|
||||
// The one this whole module exists for: a version word the driver rejects. Either the
|
||||
// driver is genuinely older than our headers, or (the sneaky case) the userspace
|
||||
// `libnvidia-encode` reports a new-enough version to the pre-flight probe but the running
|
||||
// kernel module is older and rejects the session — the classic "updated the driver, didn't
|
||||
// reboot" skew. Both heal the same way.
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => format!(
|
||||
"the NVIDIA driver is older than this build's NVENC headers (needs NVENC API {}.{} or \
|
||||
newer), or the userspace and kernel-module driver versions are mismatched — common \
|
||||
right after a driver update without a reboot. Update the NVIDIA driver, or reboot if \
|
||||
you just updated it (a host restart is the usual fix).",
|
||||
nv::NVENCAPI_MAJOR_VERSION,
|
||||
nv::NVENCAPI_MINOR_VERSION,
|
||||
),
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => {
|
||||
invalid_version(SESSION_OPENED.load(Ordering::Relaxed))
|
||||
}
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_NO_ENCODE_DEVICE => {
|
||||
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
||||
disabled on this card"
|
||||
@@ -72,9 +122,144 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify
|
||||
/// the failure class, not just print it — the bitrate-clamp search must only read a
|
||||
/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the
|
||||
/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with
|
||||
/// `err.downcast_ref::<NvCallError>()` (see [`is_param_rejection`]).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS);
|
||||
|
||||
impl std::fmt::Display for NvCallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?} — {}", self.0, explain(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NvCallError {}
|
||||
|
||||
/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request
|
||||
/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling"
|
||||
/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is
|
||||
/// environmental and must propagate instead of steering the search.
|
||||
pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool {
|
||||
matches!(
|
||||
err.downcast_ref::<NvCallError>(),
|
||||
Some(NvCallError(
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
||||
/// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The
|
||||
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
|
||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
||||
anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed"))
|
||||
}
|
||||
|
||||
/// Whether a FAILED `nvEncDestroyEncoder` status PROVES the driver holds no session for the
|
||||
/// handle — i.e. the per-process concurrent-session slot is not consumed, so the session's budget
|
||||
/// units can be refunded immediately. These are the statuses the driver returns when the session
|
||||
/// or its device no longer exists on its side (a TDR/device removal reclaims every session with
|
||||
/// the context). Everything else — `GENERIC`, `ENCODER_BUSY`, OOM, ... — is AMBIGUOUS: the slot
|
||||
/// may genuinely still be held, so the caller must park the handle fail-closed (units stay
|
||||
/// charged) and let a later retry-destroy produce the proof. Splitting on proof is what keeps the
|
||||
/// session budget from drifting low on failures (over-admitting parallel displays) WITHOUT letting
|
||||
/// one transient wedge episode permanently poison admission until a host restart.
|
||||
///
|
||||
/// Used by the Windows D3D11 backend's teardown accounting; the Linux CUDA backend has no session
|
||||
/// budget (parallel-display admission is a Windows feature), so there this exists for the unit
|
||||
/// tests only.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(super) fn destroy_proves_no_session(status: nv::NVENCSTATUS) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PTR
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_NOT_INITIALIZED
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Before any session has opened, the version word IS in question — keep the skew advice.
|
||||
#[test]
|
||||
fn invalid_version_before_any_session_blames_the_driver_version() {
|
||||
let msg = invalid_version(false);
|
||||
assert!(
|
||||
msg.contains("older than this build's NVENC headers"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(msg.contains("reboot if you just updated it"), "{msg}");
|
||||
}
|
||||
|
||||
/// Once a session has opened here, a skew is impossible — the message must stop sending
|
||||
/// operators to reboot (2026-07 field report: one stream per boot, forever).
|
||||
#[test]
|
||||
fn invalid_version_after_a_session_blames_process_state_not_the_driver() {
|
||||
let msg = invalid_version(true);
|
||||
assert!(msg.contains("NOT a driver version mismatch"), "{msg}");
|
||||
assert!(msg.contains("restart the Punktfunk host service"), "{msg}");
|
||||
assert!(
|
||||
!msg.contains("older than this build's NVENC headers"),
|
||||
"must not repeat the version-skew advice: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("Update the NVIDIA driver"),
|
||||
"must not tell the operator to update a driver that just worked: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The latch is one-way and only touches this status.
|
||||
#[test]
|
||||
fn note_session_opened_latches() {
|
||||
note_session_opened();
|
||||
assert!(SESSION_OPENED.load(Ordering::Relaxed));
|
||||
note_session_opened();
|
||||
assert!(SESSION_OPENED.load(Ordering::Relaxed));
|
||||
assert_eq!(
|
||||
explain(nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY),
|
||||
"the GPU is out of memory"
|
||||
);
|
||||
}
|
||||
|
||||
/// Destroy-failure classification: session-gone statuses refund; everything ambiguous parks.
|
||||
/// The split is the load-bearing part of the session-budget accounting — a wrong `true`
|
||||
/// under-counts (over-admits parallel displays), a wrong `false` merely defers the refund to
|
||||
/// a reap retry.
|
||||
#[test]
|
||||
fn destroy_classification_refunds_only_on_proof() {
|
||||
for gone in [
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_DEVICE_NOT_EXIST,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_ENCODERDEVICE,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PTR,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_NOT_INITIALIZED,
|
||||
] {
|
||||
assert!(
|
||||
destroy_proves_no_session(gone),
|
||||
"{gone:?} proves no session"
|
||||
);
|
||||
}
|
||||
for ambiguous in [
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_GENERIC,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_ENCODER_BUSY,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_OUT_OF_MEMORY,
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM,
|
||||
// INVALID_DEVICE sounds like the gone class but is also what a transiently-confused
|
||||
// driver returns — deliberately fail-closed (park + retry), not refunded.
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_DEVICE,
|
||||
] {
|
||||
assert!(
|
||||
!destroy_proves_no_session(ambiguous),
|
||||
"{ambiguous:?} must park fail-closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,17 @@ mod tests {
|
||||
assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32);
|
||||
assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32);
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
// …and 4:2:0 wraps it too, just later — the hole the old 4:4:4-only open guard left.
|
||||
// `Codec::max_dimension()` allows PyroWave 8192px per axis, so these modes were
|
||||
// reachable from a client-requested `mode=WxHxFPS`, and the negotiator's 4:4:4 → 4:2:0
|
||||
// downgrade routed oversized modes straight into the unguarded branch.
|
||||
// `validate_dimensions` now rejects them against this 4:2:0 count.
|
||||
assert_eq!(block_count_32x32(8192, 6144, false), 73728);
|
||||
assert_eq!(block_count_32x32(8192, 8192, false), 98304);
|
||||
assert!(block_count_32x32(8192, 6144, false) > u16::MAX as u32);
|
||||
assert!(block_count_32x32(8192, 8192, false) > u16::MAX as u32);
|
||||
// The largest 4:2:0 mode that still fits, for the boundary the validator enforces.
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The slot-family RFI (reference-frame invalidation) recovery **policy**, shared by the three
|
||||
//! backends that answer a loss with a re-reference to a known-good older frame instead of an IDR:
|
||||
//! native AMF (user-LTR bitfield), native QSV (`mfxExtRefListCtrl` LTR) and Vulkan Video (the
|
||||
//! app-owned DPB slot table). One decision, three mechanisms — the policy lived as three
|
||||
//! hand-copies and had already diverged once (the fecbec2d taint sweep reached AMF/QSV a commit
|
||||
//! before the Vulkan backend was carved out, and Vulkan shipped without it until a later fix).
|
||||
//! The NVENC twins' *range* policy is the other half of WP7.2, in
|
||||
//! [`super::nvenc_core::plan_range_recovery`].
|
||||
//!
|
||||
//! Policy only. Every mechanism — how a force is applied, how distrust is *persisted* (AMF clears
|
||||
//! its mirror slot to `None`, QSV sets a separate `ltr_tainted` flag because its mirror must keep
|
||||
//! naming the slot for the RejectedRefList, Vulkan blanks `slot_wire` to `-1` while `slot_poc`
|
||||
//! keeps the picture resident for the RPS) — stays in its backend. Callers feed their
|
||||
//! **currently-trusted** references and apply the returned taints through their own marker; that
|
||||
//! caller-side filter is exactly what makes the three persistence schemes equivalent under one
|
||||
//! pure function.
|
||||
//!
|
||||
//! The decline arm is also the backend's: AMF/QSV clear an un-consumed `pending_force` (the sweep
|
||||
//! may have emptied the slot it points at), while Vulkan deliberately leaves `pending_loss` armed
|
||||
//! (a stale arm is re-resolved at frame-build, where a failed re-pick forces the IDR that heals
|
||||
//! the stream). Do not harmonize them here.
|
||||
|
||||
/// One loss event's recovery decision over a slot table: which trusted references become
|
||||
/// untrustworthy, and which one anchors the recovery.
|
||||
pub(super) struct SlotPlan {
|
||||
/// Bitmask of slots whose reference was encoded **at or after** the loss start — inside the
|
||||
/// client's corrupt window, so the client either never received it or decoded it against a
|
||||
/// broken chain. Serving one as "known-good" on a LATER loss ships corruption tagged as the
|
||||
/// recovery anchor; the backend must record the distrust in its own persistent marker, because
|
||||
/// these wires would otherwise look like valid pre-loss anchors to the next loss event.
|
||||
pub(super) tainted: u32,
|
||||
/// The recovery anchor: the newest trusted reference **strictly older** than the loss —
|
||||
/// `(slot, wire)` — i.e. the most recent picture the client still holds intact, so
|
||||
/// re-referencing it costs the smallest residual. `None`: every candidate is inside or after
|
||||
/// the corrupt window — the caller declines and its (coalesced) keyframe path recovers.
|
||||
pub(super) anchor: Option<(usize, i64)>,
|
||||
}
|
||||
|
||||
/// Plan the recovery for a loss starting at wire frame `loss_first`, over the backend's
|
||||
/// currently-trusted references (`(slot, wire)`; previously-distrusted entries must already be
|
||||
/// filtered out by the caller — see the module doc). Sweep and pick are one call so the anchor is
|
||||
/// chosen from the same snapshot the taints are computed from, by construction: the anchor
|
||||
/// delegates to [`pick_anchor`], and `wire >= loss_first` (taint) and `wire < loss_first`
|
||||
/// (anchor) are disjoint, so a slot tainted by this call can never be this call's anchor.
|
||||
pub(super) fn plan_slot_recovery(refs: &[(usize, i64)], loss_first: i64) -> SlotPlan {
|
||||
// The callers' validity gate (`first < 0 → decline`) is what makes their sentinel filters
|
||||
// (-1 / `None`) exact views of "trusted"; this assert keeps the contract visible from inside
|
||||
// the extracted code. Plain assert: the lint legs run --release, and a compiled-out check
|
||||
// here would silently drop taints instead of failing loudly.
|
||||
assert!(
|
||||
loss_first >= 0,
|
||||
"loss_first must be validity-gated by the caller"
|
||||
);
|
||||
let mut tainted = 0u32;
|
||||
for &(slot, wire) in refs {
|
||||
if wire >= loss_first {
|
||||
assert!(slot < 32, "slot table exceeds the u32 taint mask");
|
||||
tainted |= 1 << slot;
|
||||
}
|
||||
}
|
||||
SlotPlan {
|
||||
tainted,
|
||||
anchor: pick_anchor(refs, loss_first),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick half alone: newest trusted reference strictly older than the loss. Ties break to the
|
||||
/// first entry in `refs` (every caller feeds ascending slot order, so the lowest slot wins —
|
||||
/// the strict `>` all three backends used). Standalone because Vulkan re-picks at frame-build
|
||||
/// time: its arm carries the loss start, not the slot, so the slot is resolved against the table
|
||||
/// as it stands when the recovery frame is actually encoded.
|
||||
pub(super) fn pick_anchor(refs: &[(usize, i64)], loss_first: i64) -> Option<(usize, i64)> {
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for &(slot, wire) in refs {
|
||||
if wire < loss_first && best.is_none_or(|(_, b)| wire > b) {
|
||||
best = Some((slot, wire));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_anchor, plan_slot_recovery};
|
||||
|
||||
/// Adapt a raw slot table (the Vulkan `slot_wire` shape: `-1` = empty) into the trusted view
|
||||
/// the policy takes — the same filter the backend adapters apply.
|
||||
fn view(wires: &[i64]) -> Vec<(usize, i64)> {
|
||||
wires
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(s, &w)| (w >= 0).then_some((s, w)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply a plan's taints the way the Vulkan adapter does (blank the wire) — the persistence
|
||||
/// half the pure fn hands back to the caller.
|
||||
fn apply(wires: &mut [i64], tainted: u32) {
|
||||
for (s, w) in wires.iter_mut().enumerate() {
|
||||
if tainted & (1 << s) != 0 {
|
||||
*w = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
|
||||
/// slots never qualify. (Migrated 1:1 from `vulkan_video.rs`'s `pick_recovery_slot` tests —
|
||||
/// same vectors, now `(slot, wire)`-valued.)
|
||||
#[test]
|
||||
fn picks_newest_pre_loss() {
|
||||
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
|
||||
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
|
||||
assert_eq!(pick_anchor(&view(&wires), 9), Some((0, 8)));
|
||||
// loss older than everything resident → no anchor (caller keyframes).
|
||||
assert_eq!(pick_anchor(&view(&wires), 5), None);
|
||||
// empty slots (-1) are skipped by the view filter and never anchor.
|
||||
assert_eq!(pick_anchor(&view(&[-1, 3, -1, 4]), 5), Some((3, 4)));
|
||||
assert_eq!(pick_anchor(&view(&[-1; 8]), 5), None);
|
||||
// wire == loss_first is INSIDE the corrupt window: strictly-older only.
|
||||
assert_eq!(pick_anchor(&view(&[9, 8]), 9), Some((1, 8)));
|
||||
// tie on the wire → first entry (= lowest slot) wins, the strict `>` all three backends
|
||||
// used.
|
||||
assert_eq!(pick_anchor(&[(2, 7), (5, 7)], 9), Some((2, 7)));
|
||||
// empty view.
|
||||
assert_eq!(pick_anchor(&[], 9), None);
|
||||
}
|
||||
|
||||
/// The taint sweep (fecbec2d's fix): a slot encoded inside an EARLIER, still unrepaired loss
|
||||
/// window must not become the "known-good" anchor of a LATER loss. Without persisted
|
||||
/// distrust the picker accepts it — it is resident and its wire is below the second loss
|
||||
/// start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto a
|
||||
/// reference it never decoded. (Migrated 1:1 from `vulkan_video.rs`; the hand-replicated
|
||||
/// sweep is now `plan_slot_recovery` itself.)
|
||||
#[test]
|
||||
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
|
||||
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
|
||||
// client. A second loss report arrives at wire 6 while they are all still resident.
|
||||
let tainted_wires = [4i64, 5, 6, 7];
|
||||
|
||||
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
|
||||
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
|
||||
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
|
||||
let (_, picked_wire) = pick_anchor(&view(&unswept), 6).expect("unswept picks something");
|
||||
assert!(
|
||||
tainted_wires.contains(&picked_wire),
|
||||
"precondition: without the sweep the anchor comes from the earlier loss window"
|
||||
);
|
||||
|
||||
// WITH the plan, loss 1 taints 4..7 (and anchors on wire 3 — never a tainted wire, by
|
||||
// the disjoint predicates), so loss 2 can only reach genuinely clean wires.
|
||||
let mut wires = unswept;
|
||||
let plan = plan_slot_recovery(&view(&wires), 4);
|
||||
assert_eq!(plan.tainted, 0b1111_0000);
|
||||
assert_eq!(plan.anchor, Some((3, 3)));
|
||||
apply(&mut wires, plan.tainted);
|
||||
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
|
||||
let (slot, wire) = pick_anchor(&view(&wires), 6).expect("clean wires remain");
|
||||
assert_eq!((slot, wire), (3, 3), "newest clean survivor is wire 3");
|
||||
|
||||
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
|
||||
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
|
||||
wires[4] = 8;
|
||||
wires[5] = 9;
|
||||
wires[6] = 10;
|
||||
wires[7] = 11;
|
||||
let plan = plan_slot_recovery(&view(&wires), 10);
|
||||
assert_eq!(plan.anchor, Some((5, 9)), "wire 9 is post-recovery, clean");
|
||||
apply(&mut wires, plan.tainted);
|
||||
|
||||
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
|
||||
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
|
||||
let plan = plan_slot_recovery(&view(&all), 5);
|
||||
assert_eq!(plan.tainted, 0b1111_1111);
|
||||
assert_eq!(plan.anchor, None);
|
||||
apply(&mut all, plan.tainted);
|
||||
assert_eq!(pick_anchor(&view(&all), 5), None);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,24 @@ pub struct OpenH264Encoder {
|
||||
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
|
||||
unsafe impl Send for OpenH264Encoder {}
|
||||
|
||||
/// openh264's own ceiling: level 5.2, so 3840x2160 landscape or 2160x3840 portrait.
|
||||
///
|
||||
/// The long edge may reach 3840 and the short edge 2160 — the rule is orientation-aware, not a
|
||||
/// per-axis `w <= 3840 && h <= 2160`, so a portrait 2160x3840 session is legal.
|
||||
const OPENH264_MAX_LONG_EDGE: u32 = 3840;
|
||||
const OPENH264_MAX_SHORT_EDGE: u32 = 2160;
|
||||
|
||||
/// Whether the bundled openh264 can encode this resolution at all.
|
||||
///
|
||||
/// Mirrors the check inside the crate we ship (openh264 0.9.3, `encoder.rs` `reinit`). That check
|
||||
/// runs on the FIRST ENCODE, not at encoder construction — so without this gate a too-large mode
|
||||
/// opens perfectly and then fails *every* submit, and the session connects and never delivers a
|
||||
/// frame. `Codec::max_dimension` does not cover it: it is keyed on the codec, and H.264 legitimately
|
||||
/// reaches 4096 on every hardware backend — this ceiling belongs to the software backend alone.
|
||||
fn openh264_supports_dimensions(width: u32, height: u32) -> bool {
|
||||
width.max(height) <= OPENH264_MAX_LONG_EDGE && width.min(height) <= OPENH264_MAX_SHORT_EDGE
|
||||
}
|
||||
|
||||
impl OpenH264Encoder {
|
||||
pub fn open(
|
||||
format: PixelFormat,
|
||||
@@ -59,7 +77,16 @@ impl OpenH264Encoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
) -> Result<Self> {
|
||||
// validate_dimensions() ran in open_video: even, non-zero, <= 4096.
|
||||
// validate_dimensions() ran in open_video: even, non-zero, <= 4096. That leaves modes this
|
||||
// encoder cannot serve (e.g. a legal 4096-wide H.264 mode), so refuse them here — at the
|
||||
// open, where the caller still gets a real error — rather than at every submit.
|
||||
ensure!(
|
||||
openh264_supports_dimensions(width, height),
|
||||
"openh264 cannot encode {width}x{height}: the software encoder tops out at \
|
||||
{OPENH264_MAX_LONG_EDGE}x{OPENH264_MAX_SHORT_EDGE} (or \
|
||||
{OPENH264_MAX_SHORT_EDGE}x{OPENH264_MAX_LONG_EDGE} portrait) — lower the client \
|
||||
resolution, or use a host with a hardware encoder"
|
||||
);
|
||||
let bps: u32 = bitrate_bps.try_into().unwrap_or(u32::MAX);
|
||||
let cfg = EncoderConfig::new()
|
||||
.usage_type(UsageType::ScreenContentRealTime)
|
||||
@@ -336,4 +363,40 @@ mod tests {
|
||||
.any(|w| w[0] == 0 && w[1] == 0 && w[2] == 0 && w[3] == 1 && (w[4] & 0x1f) == 7);
|
||||
assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
|
||||
}
|
||||
|
||||
/// The modes the software encoder can actually serve — including the portrait orientation,
|
||||
/// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject.
|
||||
#[test]
|
||||
fn openh264_accepts_up_to_4k_in_either_orientation() {
|
||||
assert!(openh264_supports_dimensions(1920, 1080));
|
||||
assert!(openh264_supports_dimensions(3840, 2160));
|
||||
assert!(openh264_supports_dimensions(2160, 3840));
|
||||
assert!(openh264_supports_dimensions(1080, 1920));
|
||||
}
|
||||
|
||||
/// Modes `validate_dimensions` lets through (H.264 legitimately reaches 4096 on hardware) but
|
||||
/// openh264 rejects on the first encode. Catching them at open is the whole point of the gate:
|
||||
/// otherwise the session opens and then fails every single submit.
|
||||
#[test]
|
||||
fn openh264_rejects_modes_that_would_fail_on_first_submit() {
|
||||
// 4096-wide is legal H.264 and passes `Codec::max_dimension`, but exceeds the long edge.
|
||||
assert!(!openh264_supports_dimensions(4096, 2160));
|
||||
assert!(!openh264_supports_dimensions(2160, 4096));
|
||||
// Long edge is fine, short edge is not (e.g. an ultrawide-tall composite desktop).
|
||||
assert!(!openh264_supports_dimensions(3840, 2400));
|
||||
assert!(!openh264_supports_dimensions(2400, 3840));
|
||||
}
|
||||
|
||||
/// A too-large mode must fail at `open`, not silently at every `submit`.
|
||||
#[test]
|
||||
fn open_refuses_a_mode_openh264_cannot_encode() {
|
||||
// Matched rather than `expect_err`: `OpenH264Encoder` is not `Debug` (it wraps a raw C
|
||||
// handle), so `expect_err` would not compile.
|
||||
let err = match OpenH264Encoder::open(PixelFormat::Bgra, 4096, 2160, 60, 20_000_000) {
|
||||
Ok(_) => panic!("4096x2160 exceeds openh264's long-edge ceiling and must be refused"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("openh264 cannot encode 4096x2160"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,425 +66,11 @@ use windows::Win32::Storage::FileSystem::{
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
|
||||
// slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
|
||||
// accepted ABI floor, so the mirror is valid on every runtime the loader admits).
|
||||
//
|
||||
// Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
|
||||
// pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
|
||||
// AMFPropertyStorage → AMFData → AMFBuffer/AMFSurface), so a derived pointer is usable through a
|
||||
// base vtable mirror. Slots we never call are declared as bare `*const c_void` placeholders —
|
||||
// same size/alignment as the function pointer they stand in for. `AMF_STD_CALL` is `__stdcall`
|
||||
// (Rust `extern "system"`); the two DLL entry points are `__cdecl` (`extern "C"`); on x86_64
|
||||
// both collapse to the one Windows calling convention.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
mod sys {
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// `AMF_RESULT` (core/Result.h) — a plain C enum, sequential from 0. Only the codes this
|
||||
/// module branches on are named; everything else is reported numerically via [`result_name`].
|
||||
pub type AmfResult = i32;
|
||||
pub const AMF_OK: AmfResult = 0;
|
||||
pub const AMF_EOF: AmfResult = 23;
|
||||
pub const AMF_REPEAT: AmfResult = 24;
|
||||
pub const AMF_INPUT_FULL: AmfResult = 25;
|
||||
pub const AMF_NEED_MORE_INPUT: AmfResult = 44;
|
||||
|
||||
/// Human-readable name for an `AMF_RESULT` (diagnostics only — the numeric value rides along
|
||||
/// so an unnamed code is still identifiable against Result.h).
|
||||
pub fn result_name(r: AmfResult) -> &'static str {
|
||||
match r {
|
||||
0 => "AMF_OK",
|
||||
1 => "AMF_FAIL",
|
||||
2 => "AMF_UNEXPECTED",
|
||||
3 => "AMF_ACCESS_DENIED",
|
||||
4 => "AMF_INVALID_ARG",
|
||||
5 => "AMF_OUT_OF_RANGE",
|
||||
6 => "AMF_OUT_OF_MEMORY",
|
||||
7 => "AMF_INVALID_POINTER",
|
||||
8 => "AMF_NO_INTERFACE",
|
||||
9 => "AMF_NOT_IMPLEMENTED",
|
||||
10 => "AMF_NOT_SUPPORTED",
|
||||
11 => "AMF_NOT_FOUND",
|
||||
12 => "AMF_ALREADY_INITIALIZED",
|
||||
13 => "AMF_NOT_INITIALIZED",
|
||||
14 => "AMF_INVALID_FORMAT",
|
||||
15 => "AMF_WRONG_STATE",
|
||||
17 => "AMF_NO_DEVICE",
|
||||
18 => "AMF_DIRECTX_FAILED",
|
||||
23 => "AMF_EOF",
|
||||
24 => "AMF_REPEAT",
|
||||
25 => "AMF_INPUT_FULL",
|
||||
26 => "AMF_RESOLUTION_CHANGED",
|
||||
28 => "AMF_INVALID_DATA_TYPE",
|
||||
29 => "AMF_INVALID_RESOLUTION",
|
||||
30 => "AMF_CODEC_NOT_SUPPORTED",
|
||||
31 => "AMF_SURFACE_FORMAT_NOT_SUPPORTED",
|
||||
32 => "AMF_SURFACE_MUST_BE_SHARED",
|
||||
36 => "AMF_ENCODER_NOT_PRESENT",
|
||||
44 => "AMF_NEED_MORE_INPUT",
|
||||
_ => "AMF_<unnamed>",
|
||||
}
|
||||
}
|
||||
|
||||
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
|
||||
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
|
||||
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
|
||||
/// runtime is asked only for the ABI it actually provides.
|
||||
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
||||
|
||||
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
|
||||
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
|
||||
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
|
||||
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
|
||||
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
|
||||
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
|
||||
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
|
||||
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
|
||||
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
|
||||
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
|
||||
/// never UB).
|
||||
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
|
||||
|
||||
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
|
||||
pub const AMF_SURFACE_NV12: i32 = 1;
|
||||
pub const AMF_SURFACE_P010: i32 = 10;
|
||||
|
||||
/// `AMF_DX_VERSION::AMF_DX11_1` (core/Data.h) — the `InitDX11` version argument.
|
||||
pub const AMF_DX11_1: i32 = 111;
|
||||
|
||||
/// `AMF_MEMORY_TYPE::AMF_MEMORY_HOST` (core/Data.h) — the `AllocBuffer` memory type for the
|
||||
/// CPU-filled HDR-metadata buffer.
|
||||
pub const AMF_MEMORY_HOST: i32 = 1;
|
||||
|
||||
/// `AMFHDRMetadata` (components/ColorSpace.h) — the payload of the `*InHDRMetadata` encoder
|
||||
/// property (an `AMFBuffer` holding exactly this struct). Same units as the HEVC ST.2086 SEI
|
||||
/// and [`punktfunk_core::quic::HdrMeta`]: chromaticities in 1/50000, mastering luminance in
|
||||
/// 0.0001 cd/m², CLL/FALL in nits. 28 bytes, no padding.
|
||||
#[repr(C)]
|
||||
pub struct AmfHdrMetadata {
|
||||
pub red_primary: [u16; 2],
|
||||
pub green_primary: [u16; 2],
|
||||
pub blue_primary: [u16; 2],
|
||||
pub white_point: [u16; 2],
|
||||
pub max_mastering_luminance: u32,
|
||||
pub min_mastering_luminance: u32,
|
||||
pub max_content_light_level: u16,
|
||||
pub max_frame_average_light_level: u16,
|
||||
}
|
||||
|
||||
/// `AMFGuid` (core/Platform.h) — data41..data48 flattened into an array (identical layout).
|
||||
#[repr(C)]
|
||||
pub struct AmfGuid {
|
||||
pub data1: u32,
|
||||
pub data2: u16,
|
||||
pub data3: u16,
|
||||
pub data4: [u8; 8],
|
||||
}
|
||||
|
||||
/// `IID_AMFBuffer` (core/Buffer.h `AMF_DECLARE_IID`) — for `QueryInterface` on the encoder's
|
||||
/// output `AMFData` to reach `GetNative`/`GetSize`.
|
||||
pub const IID_AMF_BUFFER: AmfGuid = AmfGuid {
|
||||
data1: 0xb04b_7248,
|
||||
data2: 0xb6f0,
|
||||
data3: 0x4321,
|
||||
data4: [0xb6, 0x91, 0xba, 0xa4, 0x74, 0x0f, 0x9f, 0xcb],
|
||||
};
|
||||
|
||||
// `AMF_VARIANT_TYPE` (core/Variant.h) — the tags this module writes/reads.
|
||||
pub const AMF_VARIANT_BOOL: i32 = 1;
|
||||
pub const AMF_VARIANT_INT64: i32 = 2;
|
||||
pub const AMF_VARIANT_RATE: i32 = 7;
|
||||
pub const AMF_VARIANT_INTERFACE: i32 = 12;
|
||||
|
||||
/// `AMFVariantStruct` (core/Variant.h): a 4-byte C-enum tag + a 16-byte union whose largest
|
||||
/// members are pointer/`amf_int64`/`AMFFloatVector4D` (align 8) → 24 bytes total, tag at 0,
|
||||
/// payload at 8. Passed BY VALUE to `SetProperty` (Win64 passes >8-byte aggregates by hidden
|
||||
/// reference on both sides, so declaring it by value matches the C compiler). The payload is
|
||||
/// stored as two fully-initialised `u64`s — little-endian packing puts a bool in byte 0, an
|
||||
/// `amf_int64` in word 0, and an `AMFRate{num,den}` as `num | den << 32`, exactly the union's
|
||||
/// in-memory layout — so no partially-initialised union bytes ever cross the FFI.
|
||||
#[repr(C)]
|
||||
pub struct AmfVariant {
|
||||
pub vtype: i32,
|
||||
pub payload: [u64; 2],
|
||||
}
|
||||
|
||||
impl AmfVariant {
|
||||
pub fn zeroed() -> Self {
|
||||
AmfVariant {
|
||||
vtype: 0, // AMF_VARIANT_EMPTY
|
||||
payload: [0, 0],
|
||||
}
|
||||
}
|
||||
pub fn from_i64(v: i64) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_INT64,
|
||||
payload: [v as u64, 0],
|
||||
}
|
||||
}
|
||||
pub fn from_bool(v: bool) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_BOOL,
|
||||
payload: [v as u64, 0],
|
||||
}
|
||||
}
|
||||
/// `AMFRate { num, den }` — two little-endian `amf_uint32`s in the union's first 8 bytes.
|
||||
pub fn from_rate(num: u32, den: u32) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_RATE,
|
||||
payload: [num as u64 | ((den as u64) << 32), 0],
|
||||
}
|
||||
}
|
||||
/// An `AMFInterface*` payload (`pInterface` in the union's first 8 bytes). The property
|
||||
/// storage AddRefs the interface when it copies the variant in (the C++ template
|
||||
/// `SetProperty(name, AMFVariant(value))` passes a temporary whose destructor releases,
|
||||
/// so `SetProperty` must take its own reference) — the caller keeps sole ownership of the
|
||||
/// reference it already holds.
|
||||
pub fn from_interface(p: *mut c_void) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_INTERFACE,
|
||||
payload: [p as usize as u64, 0],
|
||||
}
|
||||
}
|
||||
/// Read back an `amf_int64` payload (only valid when `vtype == AMF_VARIANT_INT64`).
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
(self.vtype == AMF_VARIANT_INT64).then_some(self.payload[0] as i64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for a vtable slot this module never calls — same size/align as the function
|
||||
/// pointer it stands in for, present only to keep the following slots at their C offsets.
|
||||
pub type Slot = *const c_void;
|
||||
|
||||
// -- AMFFactory (core/Factory.h; NOT refcounted — a process singleton) ----------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfFactory {
|
||||
pub vtbl: *const AmfFactoryVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfFactoryVtbl {
|
||||
pub create_context:
|
||||
unsafe extern "system" fn(*mut AmfFactory, *mut *mut AmfContext) -> AmfResult,
|
||||
pub create_component: unsafe extern "system" fn(
|
||||
*mut AmfFactory,
|
||||
*mut AmfContext,
|
||||
*const u16,
|
||||
*mut *mut AmfComponent,
|
||||
) -> AmfResult,
|
||||
pub set_cache_folder: Slot,
|
||||
pub get_cache_folder: Slot,
|
||||
pub get_debug: Slot,
|
||||
pub get_trace: Slot,
|
||||
pub get_programs: Slot,
|
||||
}
|
||||
|
||||
// -- AMFContext (core/Context.h) ------------------------------------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfContext {
|
||||
pub vtbl: *const AmfContextVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfContextVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfContext) -> i32,
|
||||
pub query_interface: Slot,
|
||||
// AMFPropertyStorage
|
||||
pub set_property: Slot,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFContext
|
||||
pub terminate: unsafe extern "system" fn(*mut AmfContext) -> AmfResult,
|
||||
pub init_dx9: Slot,
|
||||
pub get_dx9_device: Slot,
|
||||
pub lock_dx9: Slot,
|
||||
pub unlock_dx9: Slot,
|
||||
pub init_dx11: unsafe extern "system" fn(*mut AmfContext, *mut c_void, i32) -> AmfResult,
|
||||
pub get_dx11_device: Slot,
|
||||
pub lock_dx11: Slot,
|
||||
pub unlock_dx11: Slot,
|
||||
pub init_opencl: Slot,
|
||||
pub get_opencl_context: Slot,
|
||||
pub get_opencl_command_queue: Slot,
|
||||
pub get_opencl_device_id: Slot,
|
||||
pub get_opencl_compute_factory: Slot,
|
||||
pub init_opencl_ex: Slot,
|
||||
pub lock_opencl: Slot,
|
||||
pub unlock_opencl: Slot,
|
||||
pub init_opengl: Slot,
|
||||
pub get_opengl_context: Slot,
|
||||
pub get_opengl_drawable: Slot,
|
||||
pub lock_opengl: Slot,
|
||||
pub unlock_opengl: Slot,
|
||||
pub init_xv: Slot,
|
||||
pub get_xv_device: Slot,
|
||||
pub lock_xv: Slot,
|
||||
pub unlock_xv: Slot,
|
||||
pub init_gralloc: Slot,
|
||||
pub get_gralloc_device: Slot,
|
||||
pub lock_gralloc: Slot,
|
||||
pub unlock_gralloc: Slot,
|
||||
pub alloc_buffer: unsafe extern "system" fn(
|
||||
*mut AmfContext,
|
||||
i32, // AMF_MEMORY_TYPE
|
||||
usize,
|
||||
*mut *mut AmfBuffer,
|
||||
) -> AmfResult,
|
||||
pub alloc_surface: Slot,
|
||||
pub alloc_audio_buffer: Slot,
|
||||
pub create_buffer_from_host_native: Slot,
|
||||
pub create_surface_from_host_native: Slot,
|
||||
pub create_surface_from_dx9_native: Slot,
|
||||
/// Out-param is `AMFSurface**` in the header; declared as the `AmfData` base here because
|
||||
/// every surface call this module makes (`SetPts`, `SetProperty`, `Release`,
|
||||
/// `SubmitInput`) lives in the `AMFData` vtable prefix, which `AMFSurfaceVtbl` reproduces
|
||||
/// slot-for-slot (single inheritance, same object pointer).
|
||||
pub create_surface_from_dx11_native: unsafe extern "system" fn(
|
||||
*mut AmfContext,
|
||||
*mut c_void,
|
||||
*mut *mut AmfData,
|
||||
*mut c_void,
|
||||
) -> AmfResult,
|
||||
pub create_surface_from_opengl_native: Slot,
|
||||
pub create_surface_from_gralloc_native: Slot,
|
||||
pub create_surface_from_opencl_native: Slot,
|
||||
pub create_buffer_from_opencl_native: Slot,
|
||||
pub get_compute: Slot,
|
||||
}
|
||||
|
||||
// -- AMFComponent (components/Component.h) --------------------------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfComponent {
|
||||
pub vtbl: *const AmfComponentVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfComponentVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfComponent) -> i32,
|
||||
pub query_interface: Slot,
|
||||
// AMFPropertyStorage
|
||||
pub set_property:
|
||||
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFPropertyStorageEx
|
||||
pub get_properties_info_count: Slot,
|
||||
pub get_property_info_at: Slot,
|
||||
pub get_property_info: Slot,
|
||||
pub validate_property: Slot,
|
||||
// AMFComponent
|
||||
pub init: unsafe extern "system" fn(*mut AmfComponent, i32, i32, i32) -> AmfResult,
|
||||
pub reinit: Slot,
|
||||
pub terminate: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub drain: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub flush: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub submit_input: unsafe extern "system" fn(*mut AmfComponent, *mut AmfData) -> AmfResult,
|
||||
pub query_output:
|
||||
unsafe extern "system" fn(*mut AmfComponent, *mut *mut AmfData) -> AmfResult,
|
||||
pub get_context: Slot,
|
||||
pub set_output_data_allocator_cb: Slot,
|
||||
pub get_caps: Slot,
|
||||
pub optimize: Slot,
|
||||
}
|
||||
|
||||
// -- AMFData (core/Data.h) — also the usable prefix of AMFSurface --------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfData {
|
||||
pub vtbl: *const AmfDataVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfDataVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfData) -> i32,
|
||||
pub query_interface:
|
||||
unsafe extern "system" fn(*mut AmfData, *const AmfGuid, *mut *mut c_void) -> AmfResult,
|
||||
// AMFPropertyStorage
|
||||
pub set_property:
|
||||
unsafe extern "system" fn(*mut AmfData, *const u16, AmfVariant) -> AmfResult,
|
||||
pub get_property:
|
||||
unsafe extern "system" fn(*mut AmfData, *const u16, *mut AmfVariant) -> AmfResult,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFData
|
||||
pub get_memory_type: Slot,
|
||||
pub duplicate: Slot,
|
||||
pub convert: Slot,
|
||||
pub interop: Slot,
|
||||
pub get_data_type: Slot,
|
||||
pub is_reusable: Slot,
|
||||
pub set_pts: unsafe extern "system" fn(*mut AmfData, i64),
|
||||
pub get_pts: Slot,
|
||||
pub set_duration: Slot,
|
||||
pub get_duration: Slot,
|
||||
}
|
||||
|
||||
// -- AMFBuffer (core/Buffer.h) — the encoder's output object -------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfBuffer {
|
||||
pub vtbl: *const AmfBufferVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfBufferVtbl {
|
||||
// AMFInterface + AMFPropertyStorage + AMFData prefix (identical order to AmfDataVtbl).
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfBuffer) -> i32,
|
||||
pub query_interface: Slot,
|
||||
pub set_property: Slot,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
pub get_memory_type: Slot,
|
||||
pub duplicate: Slot,
|
||||
pub convert: Slot,
|
||||
pub interop: Slot,
|
||||
pub get_data_type: Slot,
|
||||
pub is_reusable: Slot,
|
||||
pub set_pts: Slot,
|
||||
pub get_pts: Slot,
|
||||
pub set_duration: Slot,
|
||||
pub get_duration: Slot,
|
||||
// AMFBuffer
|
||||
pub set_size: Slot,
|
||||
pub get_size: unsafe extern "system" fn(*mut AmfBuffer) -> usize,
|
||||
pub get_native: unsafe extern "system" fn(*mut AmfBuffer) -> *mut c_void,
|
||||
pub add_observer_buffer: Slot,
|
||||
pub remove_observer_buffer: Slot,
|
||||
}
|
||||
|
||||
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
|
||||
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
|
||||
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
|
||||
}
|
||||
// The hand-mirrored AMF C ABI lives in `amf_sys.rs` (WP7.4): pure FFI surface, no policy —
|
||||
// isolating the unsafe vtable mirror from the encoder logic is the crate's stated review
|
||||
// goal, and the `#[path]` keeps the module name (`sys::`) and every call site unchanged.
|
||||
#[path = "amf_sys.rs"]
|
||||
mod sys;
|
||||
|
||||
use sys::{result_name, AmfVariant};
|
||||
|
||||
@@ -1272,7 +858,11 @@ impl AmfEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
|
||||
}
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||
// capture derived `expected = P010`, tripped the format check below and ended the session
|
||||
// at open — on exactly the configuration the capturer had already downgraded on purpose.
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
// Zero-copy by construction: the input ring is NV12/P010 fed by same-format
|
||||
// CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback,
|
||||
// CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade
|
||||
@@ -2369,31 +1959,27 @@ impl Encoder for AmfEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
||||
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
for marked in self.ltr_slots.iter_mut() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||
// shared with QSV and Vulkan Video); this backend's mechanism is: distrust = clear the
|
||||
// mirror slot (dropped slots stay dropped; the cadence re-marks a clean frame within
|
||||
// ~1/4 s). `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed`
|
||||
// pins `frame_idx` to it per submission), so they compare directly against the client's
|
||||
// `first` — and stay comparable across encoder rebuilds/resets, where an internal counter
|
||||
// would make the pre-loss check vacuous and risk force-referencing an LTR marked INSIDE
|
||||
// the lost range.
|
||||
let view: Vec<(usize, i64)> = self
|
||||
.ltr_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(s, m)| m.map(|w| (s, w)))
|
||||
.collect();
|
||||
let plan = super::rfi::plan_slot_recovery(&view, first);
|
||||
for (slot, marked) in self.ltr_slots.iter_mut().enumerate() {
|
||||
if plan.tainted & (1 << slot) != 0 {
|
||||
*marked = None;
|
||||
}
|
||||
}
|
||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
||||
// `frame_idx` to it per submission), so they compare directly against the client's `first`
|
||||
// — and stay comparable across encoder rebuilds/resets, where an internal counter would
|
||||
// make this check vacuous and risk force-referencing an LTR marked INSIDE the lost range.
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if let Some(idx) = *marked {
|
||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||
best = Some((slot, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
match plan.anchor {
|
||||
Some((slot, ltr_frame)) => {
|
||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||
self.pending_force = Some(slot);
|
||||
@@ -2422,6 +2008,8 @@ impl Encoder for AmfEncoder {
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
|
||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||
@@ -2760,6 +2348,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty.
|
||||
/// Gated like its only caller, the `amf-qsv`-only §5.2 latency A/B below — otherwise a
|
||||
/// `--features nvenc,qsv` build compiles this helper with the benchmark cfg'd out and trips
|
||||
/// `dead_code` (which the crate root no longer blanket-allows).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
fn percentile(samples: &mut [u128], p: f64) -> u128 {
|
||||
if samples.is_empty() {
|
||||
return 0;
|
||||
@@ -2778,6 +2370,8 @@ mod tests {
|
||||
/// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path
|
||||
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
|
||||
/// submit→AU.
|
||||
/// Gated like its only caller (see [`percentile`]).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_and_measure(
|
||||
enc: &mut dyn Encoder,
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
|
||||
//! slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
|
||||
//! accepted ABI floor, so the mirror is valid on every runtime the loader admits).
|
||||
//!
|
||||
//! Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
|
||||
//! pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
|
||||
//! AMFPropertyStorage → AMFData → AMFBuffer/AMFSurface), so a derived pointer is usable through a
|
||||
//! base vtable mirror. Slots we never call are declared as bare `*const c_void` placeholders —
|
||||
//! same size/alignment as the function pointer they stand in for. `AMF_STD_CALL` is `__stdcall`
|
||||
//! (Rust `extern "system"`); the two DLL entry points are `__cdecl` (`extern "C"`); on x86_64
|
||||
//! both collapse to the one Windows calling convention.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// `AMF_RESULT` (core/Result.h) — a plain C enum, sequential from 0. Only the codes this
|
||||
/// module branches on are named; everything else is reported numerically via [`result_name`].
|
||||
pub type AmfResult = i32;
|
||||
pub const AMF_OK: AmfResult = 0;
|
||||
pub const AMF_EOF: AmfResult = 23;
|
||||
pub const AMF_REPEAT: AmfResult = 24;
|
||||
pub const AMF_INPUT_FULL: AmfResult = 25;
|
||||
pub const AMF_NEED_MORE_INPUT: AmfResult = 44;
|
||||
|
||||
/// Human-readable name for an `AMF_RESULT` (diagnostics only — the numeric value rides along
|
||||
/// so an unnamed code is still identifiable against Result.h).
|
||||
pub fn result_name(r: AmfResult) -> &'static str {
|
||||
match r {
|
||||
0 => "AMF_OK",
|
||||
1 => "AMF_FAIL",
|
||||
2 => "AMF_UNEXPECTED",
|
||||
3 => "AMF_ACCESS_DENIED",
|
||||
4 => "AMF_INVALID_ARG",
|
||||
5 => "AMF_OUT_OF_RANGE",
|
||||
6 => "AMF_OUT_OF_MEMORY",
|
||||
7 => "AMF_INVALID_POINTER",
|
||||
8 => "AMF_NO_INTERFACE",
|
||||
9 => "AMF_NOT_IMPLEMENTED",
|
||||
10 => "AMF_NOT_SUPPORTED",
|
||||
11 => "AMF_NOT_FOUND",
|
||||
12 => "AMF_ALREADY_INITIALIZED",
|
||||
13 => "AMF_NOT_INITIALIZED",
|
||||
14 => "AMF_INVALID_FORMAT",
|
||||
15 => "AMF_WRONG_STATE",
|
||||
17 => "AMF_NO_DEVICE",
|
||||
18 => "AMF_DIRECTX_FAILED",
|
||||
23 => "AMF_EOF",
|
||||
24 => "AMF_REPEAT",
|
||||
25 => "AMF_INPUT_FULL",
|
||||
26 => "AMF_RESOLUTION_CHANGED",
|
||||
28 => "AMF_INVALID_DATA_TYPE",
|
||||
29 => "AMF_INVALID_RESOLUTION",
|
||||
30 => "AMF_CODEC_NOT_SUPPORTED",
|
||||
31 => "AMF_SURFACE_FORMAT_NOT_SUPPORTED",
|
||||
32 => "AMF_SURFACE_MUST_BE_SHARED",
|
||||
36 => "AMF_ENCODER_NOT_PRESENT",
|
||||
44 => "AMF_NEED_MORE_INPUT",
|
||||
_ => "AMF_<unnamed>",
|
||||
}
|
||||
}
|
||||
|
||||
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
|
||||
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
|
||||
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
|
||||
/// runtime is asked only for the ABI it actually provides.
|
||||
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
||||
|
||||
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
|
||||
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
|
||||
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
|
||||
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
|
||||
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
|
||||
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
|
||||
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
|
||||
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
|
||||
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
|
||||
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
|
||||
/// never UB).
|
||||
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
|
||||
|
||||
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
|
||||
pub const AMF_SURFACE_NV12: i32 = 1;
|
||||
pub const AMF_SURFACE_P010: i32 = 10;
|
||||
|
||||
/// `AMF_DX_VERSION::AMF_DX11_1` (core/Data.h) — the `InitDX11` version argument.
|
||||
pub const AMF_DX11_1: i32 = 111;
|
||||
|
||||
/// `AMF_MEMORY_TYPE::AMF_MEMORY_HOST` (core/Data.h) — the `AllocBuffer` memory type for the
|
||||
/// CPU-filled HDR-metadata buffer.
|
||||
pub const AMF_MEMORY_HOST: i32 = 1;
|
||||
|
||||
/// `AMFHDRMetadata` (components/ColorSpace.h) — the payload of the `*InHDRMetadata` encoder
|
||||
/// property (an `AMFBuffer` holding exactly this struct). Same units as the HEVC ST.2086 SEI
|
||||
/// and [`punktfunk_core::quic::HdrMeta`]: chromaticities in 1/50000, mastering luminance in
|
||||
/// 0.0001 cd/m², CLL/FALL in nits. 28 bytes, no padding.
|
||||
#[repr(C)]
|
||||
pub struct AmfHdrMetadata {
|
||||
pub red_primary: [u16; 2],
|
||||
pub green_primary: [u16; 2],
|
||||
pub blue_primary: [u16; 2],
|
||||
pub white_point: [u16; 2],
|
||||
pub max_mastering_luminance: u32,
|
||||
pub min_mastering_luminance: u32,
|
||||
pub max_content_light_level: u16,
|
||||
pub max_frame_average_light_level: u16,
|
||||
}
|
||||
|
||||
/// `AMFGuid` (core/Platform.h) — data41..data48 flattened into an array (identical layout).
|
||||
#[repr(C)]
|
||||
pub struct AmfGuid {
|
||||
pub data1: u32,
|
||||
pub data2: u16,
|
||||
pub data3: u16,
|
||||
pub data4: [u8; 8],
|
||||
}
|
||||
|
||||
/// `IID_AMFBuffer` (core/Buffer.h `AMF_DECLARE_IID`) — for `QueryInterface` on the encoder's
|
||||
/// output `AMFData` to reach `GetNative`/`GetSize`.
|
||||
pub const IID_AMF_BUFFER: AmfGuid = AmfGuid {
|
||||
data1: 0xb04b_7248,
|
||||
data2: 0xb6f0,
|
||||
data3: 0x4321,
|
||||
data4: [0xb6, 0x91, 0xba, 0xa4, 0x74, 0x0f, 0x9f, 0xcb],
|
||||
};
|
||||
|
||||
// `AMF_VARIANT_TYPE` (core/Variant.h) — the tags this module writes/reads.
|
||||
pub const AMF_VARIANT_BOOL: i32 = 1;
|
||||
pub const AMF_VARIANT_INT64: i32 = 2;
|
||||
pub const AMF_VARIANT_RATE: i32 = 7;
|
||||
pub const AMF_VARIANT_INTERFACE: i32 = 12;
|
||||
|
||||
/// `AMFVariantStruct` (core/Variant.h): a 4-byte C-enum tag + a 16-byte union whose largest
|
||||
/// members are pointer/`amf_int64`/`AMFFloatVector4D` (align 8) → 24 bytes total, tag at 0,
|
||||
/// payload at 8. Passed BY VALUE to `SetProperty` (Win64 passes >8-byte aggregates by hidden
|
||||
/// reference on both sides, so declaring it by value matches the C compiler). The payload is
|
||||
/// stored as two fully-initialised `u64`s — little-endian packing puts a bool in byte 0, an
|
||||
/// `amf_int64` in word 0, and an `AMFRate{num,den}` as `num | den << 32`, exactly the union's
|
||||
/// in-memory layout — so no partially-initialised union bytes ever cross the FFI.
|
||||
#[repr(C)]
|
||||
pub struct AmfVariant {
|
||||
pub vtype: i32,
|
||||
pub payload: [u64; 2],
|
||||
}
|
||||
|
||||
impl AmfVariant {
|
||||
pub fn zeroed() -> Self {
|
||||
AmfVariant {
|
||||
vtype: 0, // AMF_VARIANT_EMPTY
|
||||
payload: [0, 0],
|
||||
}
|
||||
}
|
||||
pub fn from_i64(v: i64) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_INT64,
|
||||
payload: [v as u64, 0],
|
||||
}
|
||||
}
|
||||
pub fn from_bool(v: bool) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_BOOL,
|
||||
payload: [v as u64, 0],
|
||||
}
|
||||
}
|
||||
/// `AMFRate { num, den }` — two little-endian `amf_uint32`s in the union's first 8 bytes.
|
||||
pub fn from_rate(num: u32, den: u32) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_RATE,
|
||||
payload: [num as u64 | ((den as u64) << 32), 0],
|
||||
}
|
||||
}
|
||||
/// An `AMFInterface*` payload (`pInterface` in the union's first 8 bytes). The property
|
||||
/// storage AddRefs the interface when it copies the variant in (the C++ template
|
||||
/// `SetProperty(name, AMFVariant(value))` passes a temporary whose destructor releases,
|
||||
/// so `SetProperty` must take its own reference) — the caller keeps sole ownership of the
|
||||
/// reference it already holds.
|
||||
pub fn from_interface(p: *mut c_void) -> Self {
|
||||
AmfVariant {
|
||||
vtype: AMF_VARIANT_INTERFACE,
|
||||
payload: [p as usize as u64, 0],
|
||||
}
|
||||
}
|
||||
/// Read back an `amf_int64` payload (only valid when `vtype == AMF_VARIANT_INT64`).
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
(self.vtype == AMF_VARIANT_INT64).then_some(self.payload[0] as i64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for a vtable slot this module never calls — same size/align as the function
|
||||
/// pointer it stands in for, present only to keep the following slots at their C offsets.
|
||||
pub type Slot = *const c_void;
|
||||
|
||||
// -- AMFFactory (core/Factory.h; NOT refcounted — a process singleton) ----------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfFactory {
|
||||
pub vtbl: *const AmfFactoryVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfFactoryVtbl {
|
||||
pub create_context:
|
||||
unsafe extern "system" fn(*mut AmfFactory, *mut *mut AmfContext) -> AmfResult,
|
||||
pub create_component: unsafe extern "system" fn(
|
||||
*mut AmfFactory,
|
||||
*mut AmfContext,
|
||||
*const u16,
|
||||
*mut *mut AmfComponent,
|
||||
) -> AmfResult,
|
||||
pub set_cache_folder: Slot,
|
||||
pub get_cache_folder: Slot,
|
||||
pub get_debug: Slot,
|
||||
pub get_trace: Slot,
|
||||
pub get_programs: Slot,
|
||||
}
|
||||
|
||||
// -- AMFContext (core/Context.h) ------------------------------------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfContext {
|
||||
pub vtbl: *const AmfContextVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfContextVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfContext) -> i32,
|
||||
pub query_interface: Slot,
|
||||
// AMFPropertyStorage
|
||||
pub set_property: Slot,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFContext
|
||||
pub terminate: unsafe extern "system" fn(*mut AmfContext) -> AmfResult,
|
||||
pub init_dx9: Slot,
|
||||
pub get_dx9_device: Slot,
|
||||
pub lock_dx9: Slot,
|
||||
pub unlock_dx9: Slot,
|
||||
pub init_dx11: unsafe extern "system" fn(*mut AmfContext, *mut c_void, i32) -> AmfResult,
|
||||
pub get_dx11_device: Slot,
|
||||
pub lock_dx11: Slot,
|
||||
pub unlock_dx11: Slot,
|
||||
pub init_opencl: Slot,
|
||||
pub get_opencl_context: Slot,
|
||||
pub get_opencl_command_queue: Slot,
|
||||
pub get_opencl_device_id: Slot,
|
||||
pub get_opencl_compute_factory: Slot,
|
||||
pub init_opencl_ex: Slot,
|
||||
pub lock_opencl: Slot,
|
||||
pub unlock_opencl: Slot,
|
||||
pub init_opengl: Slot,
|
||||
pub get_opengl_context: Slot,
|
||||
pub get_opengl_drawable: Slot,
|
||||
pub lock_opengl: Slot,
|
||||
pub unlock_opengl: Slot,
|
||||
pub init_xv: Slot,
|
||||
pub get_xv_device: Slot,
|
||||
pub lock_xv: Slot,
|
||||
pub unlock_xv: Slot,
|
||||
pub init_gralloc: Slot,
|
||||
pub get_gralloc_device: Slot,
|
||||
pub lock_gralloc: Slot,
|
||||
pub unlock_gralloc: Slot,
|
||||
pub alloc_buffer: unsafe extern "system" fn(
|
||||
*mut AmfContext,
|
||||
i32, // AMF_MEMORY_TYPE
|
||||
usize,
|
||||
*mut *mut AmfBuffer,
|
||||
) -> AmfResult,
|
||||
pub alloc_surface: Slot,
|
||||
pub alloc_audio_buffer: Slot,
|
||||
pub create_buffer_from_host_native: Slot,
|
||||
pub create_surface_from_host_native: Slot,
|
||||
pub create_surface_from_dx9_native: Slot,
|
||||
/// Out-param is `AMFSurface**` in the header; declared as the `AmfData` base here because
|
||||
/// every surface call this module makes (`SetPts`, `SetProperty`, `Release`,
|
||||
/// `SubmitInput`) lives in the `AMFData` vtable prefix, which `AMFSurfaceVtbl` reproduces
|
||||
/// slot-for-slot (single inheritance, same object pointer).
|
||||
pub create_surface_from_dx11_native: unsafe extern "system" fn(
|
||||
*mut AmfContext,
|
||||
*mut c_void,
|
||||
*mut *mut AmfData,
|
||||
*mut c_void,
|
||||
) -> AmfResult,
|
||||
pub create_surface_from_opengl_native: Slot,
|
||||
pub create_surface_from_gralloc_native: Slot,
|
||||
pub create_surface_from_opencl_native: Slot,
|
||||
pub create_buffer_from_opencl_native: Slot,
|
||||
pub get_compute: Slot,
|
||||
}
|
||||
|
||||
// -- AMFComponent (components/Component.h) --------------------------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfComponent {
|
||||
pub vtbl: *const AmfComponentVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfComponentVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfComponent) -> i32,
|
||||
pub query_interface: Slot,
|
||||
// AMFPropertyStorage
|
||||
pub set_property:
|
||||
unsafe extern "system" fn(*mut AmfComponent, *const u16, AmfVariant) -> AmfResult,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFPropertyStorageEx
|
||||
pub get_properties_info_count: Slot,
|
||||
pub get_property_info_at: Slot,
|
||||
pub get_property_info: Slot,
|
||||
pub validate_property: Slot,
|
||||
// AMFComponent
|
||||
pub init: unsafe extern "system" fn(*mut AmfComponent, i32, i32, i32) -> AmfResult,
|
||||
pub reinit: Slot,
|
||||
pub terminate: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub drain: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub flush: unsafe extern "system" fn(*mut AmfComponent) -> AmfResult,
|
||||
pub submit_input: unsafe extern "system" fn(*mut AmfComponent, *mut AmfData) -> AmfResult,
|
||||
pub query_output: unsafe extern "system" fn(*mut AmfComponent, *mut *mut AmfData) -> AmfResult,
|
||||
pub get_context: Slot,
|
||||
pub set_output_data_allocator_cb: Slot,
|
||||
pub get_caps: Slot,
|
||||
pub optimize: Slot,
|
||||
}
|
||||
|
||||
// -- AMFData (core/Data.h) — also the usable prefix of AMFSurface --------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfData {
|
||||
pub vtbl: *const AmfDataVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfDataVtbl {
|
||||
// AMFInterface
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfData) -> i32,
|
||||
pub query_interface:
|
||||
unsafe extern "system" fn(*mut AmfData, *const AmfGuid, *mut *mut c_void) -> AmfResult,
|
||||
// AMFPropertyStorage
|
||||
pub set_property: unsafe extern "system" fn(*mut AmfData, *const u16, AmfVariant) -> AmfResult,
|
||||
pub get_property:
|
||||
unsafe extern "system" fn(*mut AmfData, *const u16, *mut AmfVariant) -> AmfResult,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
// AMFData
|
||||
pub get_memory_type: Slot,
|
||||
pub duplicate: Slot,
|
||||
pub convert: Slot,
|
||||
pub interop: Slot,
|
||||
pub get_data_type: Slot,
|
||||
pub is_reusable: Slot,
|
||||
pub set_pts: unsafe extern "system" fn(*mut AmfData, i64),
|
||||
pub get_pts: Slot,
|
||||
pub set_duration: Slot,
|
||||
pub get_duration: Slot,
|
||||
}
|
||||
|
||||
// -- AMFBuffer (core/Buffer.h) — the encoder's output object -------------------------------
|
||||
#[repr(C)]
|
||||
pub struct AmfBuffer {
|
||||
pub vtbl: *const AmfBufferVtbl,
|
||||
}
|
||||
#[repr(C)]
|
||||
pub struct AmfBufferVtbl {
|
||||
// AMFInterface + AMFPropertyStorage + AMFData prefix (identical order to AmfDataVtbl).
|
||||
pub acquire: Slot,
|
||||
pub release: unsafe extern "system" fn(*mut AmfBuffer) -> i32,
|
||||
pub query_interface: Slot,
|
||||
pub set_property: Slot,
|
||||
pub get_property: Slot,
|
||||
pub has_property: Slot,
|
||||
pub get_property_count: Slot,
|
||||
pub get_property_at: Slot,
|
||||
pub clear: Slot,
|
||||
pub add_to: Slot,
|
||||
pub copy_to: Slot,
|
||||
pub add_observer: Slot,
|
||||
pub remove_observer: Slot,
|
||||
pub get_memory_type: Slot,
|
||||
pub duplicate: Slot,
|
||||
pub convert: Slot,
|
||||
pub interop: Slot,
|
||||
pub get_data_type: Slot,
|
||||
pub is_reusable: Slot,
|
||||
pub set_pts: Slot,
|
||||
pub get_pts: Slot,
|
||||
pub set_duration: Slot,
|
||||
pub get_duration: Slot,
|
||||
// AMFBuffer
|
||||
pub set_size: Slot,
|
||||
pub get_size: unsafe extern "system" fn(*mut AmfBuffer) -> usize,
|
||||
pub get_native: unsafe extern "system" fn(*mut AmfBuffer) -> *mut c_void,
|
||||
pub add_observer_buffer: Slot,
|
||||
pub remove_observer_buffer: Slot,
|
||||
}
|
||||
|
||||
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
|
||||
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
|
||||
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
|
||||
@@ -95,6 +95,13 @@ struct AVD3D11VAFramesContext {
|
||||
/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WinVendor {
|
||||
/// Benchmark-only, as the module header explains: native AMF replaced the libavcodec AMF path
|
||||
/// in production, and the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]`
|
||||
/// latency A/B in `amf.rs` — the measurement that justifies the native backend existing. That
|
||||
/// is test code, so the *lib* target constructs it nowhere and `dead_code` fires on it (the
|
||||
/// crate root no longer blanket-allows that). Kept deliberately rather than deleted; the arms
|
||||
/// below are what the benchmark drives.
|
||||
#[allow(dead_code)]
|
||||
Amf,
|
||||
Qsv,
|
||||
}
|
||||
@@ -127,6 +134,44 @@ fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||
}
|
||||
|
||||
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
|
||||
/// AU, so a value past one frame period is already self-defeating and a full second is far beyond
|
||||
/// anything an operator would set on purpose. The clamp is also what makes the µs conversion
|
||||
/// below provably overflow-free.
|
||||
///
|
||||
/// The reachable hazard is a slipped digit, not the overflow: pre-clamp, `PUNKTFUNK_FFWIN_POLL_MS=
|
||||
/// 100000000` was a **27.7-hour** spin with no overflow anywhere near it.
|
||||
const MAX_POLL_SPIN_MS: u64 = 1_000;
|
||||
|
||||
/// Bounded post-submit spin for [`FfmpegWinEncoder::poll`], in microseconds (0 = off, the default
|
||||
/// and the correct choice on every VCN measured so far).
|
||||
///
|
||||
/// Read from the environment **once per process** (WP6.1): `poll` runs once per encode tick, and
|
||||
/// this was an unconditional `env::var` + parse on it.
|
||||
///
|
||||
/// ⚠ The audit proposed `saturating_mul` for the µs conversion. It is still the wrong fix, but for
|
||||
/// a reason worth stating precisely, because the obvious one is false: `Duration::from_micros(
|
||||
/// u64::MAX)` is only ~1.8e13 seconds, six orders of magnitude below `Duration`'s `u64::MAX`-second
|
||||
/// ceiling, so `Instant::now() + Duration::from_micros(u64::MAX)` does **not** overflow and does
|
||||
/// **not** panic (measured, both with and without debug assertions). What it does instead is set a
|
||||
/// deadline ~584,000 years out, and the loop below only exits on `Packet`/`Eof` — and this
|
||||
/// function's own doc explains that a spin here *provably never* produces the owed AU on the
|
||||
/// measured hardware. So `saturating_mul` converts a bad value into a **permanently wedged encode
|
||||
/// thread**: a hang, not a panic. Clamping the parsed value first removes the bad value entirely.
|
||||
///
|
||||
/// (For the record on the pre-clamp behaviour: the workspace sets no `overflow-checks` in
|
||||
/// `[profile.release]`, so `ms * 1000` wrapped silently in release and panicked only in debug.)
|
||||
fn poll_spin_cap_us() -> u64 {
|
||||
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
*CAP_US.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||
.unwrap_or(0) // default: no spin — the libavcodec AMF buffer can't be spun out
|
||||
})
|
||||
}
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||
fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
Ok(match format {
|
||||
@@ -151,8 +196,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
}
|
||||
|
||||
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
|
||||
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
|
||||
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
///
|
||||
/// Depth follows the PIXELS, not the negotiated `bit_depth` — see
|
||||
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
|
||||
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
|
||||
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
|
||||
/// because the rebuild re-derived the same wrong answer.
|
||||
fn is_10bit_format(format: PixelFormat) -> bool {
|
||||
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||
@@ -271,6 +322,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Gated to the builds that can actually reach it: `lib.rs`'s only caller sits under
|
||||
/// `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native VPL backend
|
||||
/// compiled in it is `qsv::probe_can_encode` that answers. So in the SHIPPED Windows combo
|
||||
/// (`nvenc,amf-qsv,qsv`) this function has no caller at all.
|
||||
#[cfg(not(feature = "qsv"))]
|
||||
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
// Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
|
||||
// the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
|
||||
@@ -350,7 +406,7 @@ impl SystemInner {
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -472,7 +528,10 @@ impl SystemInner {
|
||||
pts: i64,
|
||||
idr: bool,
|
||||
) -> Result<()> {
|
||||
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
|
||||
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
|
||||
// merely negotiated 10-bit over an 8-bit capture.
|
||||
let fmt_10 = is_10bit_format(format);
|
||||
anyhow::ensure!(
|
||||
fmt_10 == self.ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
@@ -851,7 +910,7 @@ impl ZeroCopyInner {
|
||||
bit_depth: u8,
|
||||
device: &ID3D11Device,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -1311,11 +1370,7 @@ impl Encoder for FfmpegWinEncoder {
|
||||
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let cap_us = std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(|ms| ms * 1000)
|
||||
.unwrap_or(0); // default: no spin — the libavcodec AMF buffer can't be spun out
|
||||
let cap_us = poll_spin_cap_us();
|
||||
let deadline = (cap_us > 0 && self.in_flight > 0)
|
||||
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
||||
loop {
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey,
|
||||
LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -263,6 +264,112 @@ fn split_mode_units(split_mode: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes every `nvEncOpenEncodeSessionEx` in this process against [`reap_parked_sessions`].
|
||||
/// The reap retry-destroys handles whose driver-side state is unknown; if it overlapped an open,
|
||||
/// the driver could hand the NEW session the recycled address of the zombie being destroyed and
|
||||
/// the reap would destroy a live session. Held across `init_session`'s whole open sequence
|
||||
/// (including its caps probe and bitrate-clamp search) and around the standalone caps probes.
|
||||
/// Admission (`can_open_another_session`) never takes it — that stays a lock-free atomic load.
|
||||
static DRIVER_SESSION_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// A session whose `destroy_encoder` failed with an AMBIGUOUS status (see
|
||||
/// [`nvenc_status::destroy_proves_no_session`]): the driver may still hold its concurrent-session
|
||||
/// slot, so its units stay charged in [`LIVE_SESSION_UNITS`] (fail-closed for admission) and the
|
||||
/// handle is parked here for [`reap_parked_sessions`] to retry. The retry is what keeps a
|
||||
/// TRANSIENT failure (wedge episode a TDR later cleared) from poisoning the budget until a host
|
||||
/// restart, while a genuine driver leak keeps failing the retry and stays correctly charged.
|
||||
struct ParkedSession {
|
||||
enc: usize,
|
||||
units: u32,
|
||||
/// Pins the D3D11 device the session was opened against. Teardown drops the texture
|
||||
/// registrations (the only other refs), and the reinit-on-device-change path parks exactly
|
||||
/// when the old device is on its way out — without this, the reap's late `destroy_encoder`
|
||||
/// could touch a freed device.
|
||||
_device: Option<ID3D11Device>,
|
||||
}
|
||||
|
||||
// SAFETY: the COM ref is the only non-Send field. The D3D11 device is free-threaded (capture
|
||||
// creates it without `D3D11_CREATE_DEVICE_SINGLETHREADED` — see pf-frame/src/dxgi.rs), we never
|
||||
// call methods on it from here, and dropping (Release) a free-threaded COM object from another
|
||||
// thread is sound. The raw `enc` handle is only ever passed back to `destroy_encoder` under
|
||||
// [`PARKED`]'s lock with [`DRIVER_SESSION_GATE`] held — single-threaded access in practice.
|
||||
unsafe impl Send for ParkedSession {}
|
||||
|
||||
static PARKED: std::sync::Mutex<Vec<ParkedSession>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Park a session whose destroy failed ambiguously. Units are NOT refunded — they keep counting
|
||||
/// against admission until a reap proves the slot free. Bounded: once parked units would exceed
|
||||
/// the session cap the oldest entry ages out WITH a refund (a repeated wedge-rebuild loop would
|
||||
/// otherwise grow this without limit, and by then the driver has almost certainly been through
|
||||
/// the reset that reclaims the early handles anyway).
|
||||
fn park_session(enc: usize, units: u32, device: Option<ID3D11Device>) {
|
||||
let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
|
||||
while !parked.is_empty() && parked.iter().map(|z| z.units).sum::<u32>() + units > session_cap()
|
||||
{
|
||||
let old = parked.remove(0);
|
||||
LIVE_SESSION_UNITS.fetch_sub(old.units, std::sync::atomic::Ordering::Relaxed);
|
||||
tracing::warn!(
|
||||
enc = old.enc,
|
||||
units = old.units,
|
||||
"NVENC parked-session graveyard full — aging out the oldest entry (units refunded)"
|
||||
);
|
||||
}
|
||||
parked.push(ParkedSession {
|
||||
enc,
|
||||
units,
|
||||
_device: device,
|
||||
});
|
||||
}
|
||||
|
||||
/// Retry `destroy_encoder` on every parked session and refund the units of each one that now
|
||||
/// succeeds (or fails with a session-gone status — same proof). Runs from `init_session` on the
|
||||
/// encode thread, NEVER from admission (a wedged driver would block the admission registry lock).
|
||||
///
|
||||
/// # Safety
|
||||
/// Caller must hold [`DRIVER_SESSION_GATE`] and there must be NO live NVENC session in the
|
||||
/// process (checked here: live units == parked units). Under those two conditions no live session
|
||||
/// can alias a recycled handle address, and no concurrent open can be handed one mid-reap. The
|
||||
/// residual assumption — documented, unprovable from the SDK — is that a FAILED destroy leaves
|
||||
/// the handle itself intact for a later retry; NVENC documents no retry semantics either way.
|
||||
unsafe fn reap_parked_sessions() {
|
||||
let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if parked.is_empty() {
|
||||
return;
|
||||
}
|
||||
let parked_units: u32 = parked.iter().map(|z| z.units).sum();
|
||||
if LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) != parked_units {
|
||||
return; // a live session exists somewhere — its address space is off limits
|
||||
}
|
||||
parked.retain(
|
||||
|z| match (api().destroy_encoder)(z.enc as *mut c_void).nv_ok() {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
enc = z.enc,
|
||||
units = z.units,
|
||||
"NVENC parked session reclaimed — retry-destroy succeeded, budget refunded"
|
||||
);
|
||||
LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
Err(e) if nvenc_status::destroy_proves_no_session(e) => {
|
||||
tracing::info!(
|
||||
enc = z.enc,
|
||||
units = z.units,
|
||||
status = ?e,
|
||||
"NVENC parked session gone on the driver side — budget refunded"
|
||||
);
|
||||
LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(enc = z.enc, status = ?e,
|
||||
"NVENC parked session still refuses destroy — units stay charged");
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy).
|
||||
/// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until
|
||||
/// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset
|
||||
@@ -279,12 +386,24 @@ fn async_retrieve_requested() -> bool {
|
||||
/// the ring textures in place, so in-flight depth beyond the ring lets the capturer overwrite a
|
||||
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
|
||||
/// `PUNKTFUNK_IDD_DEPTH`; raise both together if deeper pipelining is needed.
|
||||
///
|
||||
/// Read from the environment **once per process** (WP6.1): `submit` consults this on EVERY frame —
|
||||
/// both arms of the `cap` match, in sync mode too, where the result is then discarded because the
|
||||
/// backpressure loop short-circuits on `async_rt`. Memoizing rather than latching into a session
|
||||
/// field is deliberate: the composed `cap` also folds in `input_ring_depth`, which
|
||||
/// `set_input_ring_depth` may change after open, and freezing that half would reintroduce the
|
||||
/// in-place-overwrite bug the ring term exists to prevent. Nothing in the workspace mutates this
|
||||
/// variable at runtime, so memoizing a process-constant read is behaviour-preserving by
|
||||
/// construction.
|
||||
fn async_inflight_cap() -> usize {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(2, POOL - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to lock once its
|
||||
@@ -323,7 +442,23 @@ fn retrieve_loop(
|
||||
done_tx: mpsc::Sender<RetrieveDone>,
|
||||
) {
|
||||
pf_frame::thread_qos::boost_thread_priority(false);
|
||||
// Wedged-vs-routine drain (WP5.2b). Teardown drops the job sender and joins this thread, so
|
||||
// every queued job is WAITED for (never abandoned — abandoning converts the routine drain
|
||||
// into destroy/unmap/close-while-encoding). But on a wedged session that used to cost the
|
||||
// full 5 s PER queued job: up to `cap x 5 s` on the encode thread, and the stall-recovery
|
||||
// ladder rebuilds the session several times per episode, paying it each rebuild. The wedge
|
||||
// evidence lives right here — a completion wait that burned its full budget — so after one
|
||||
// full-budget timeout later jobs get a short slice instead. A first timeout is already
|
||||
// encoder-fatal (its Err reaches `absorb_done` before any later completion is handed out,
|
||||
// and the host tears the session down), so short-slicing behind an established wedge changes
|
||||
// only how long teardown blocks, never an outcome. A successful wait resets the latch, which
|
||||
// keeps a transient GPU stall from cascading short slices into false timeouts. (NVENC does
|
||||
// not document completion ordering across in-flight frames; nothing here relies on it —
|
||||
// FIFO only sharpens the latency bound.)
|
||||
const WEDGED_DRAIN_WAIT_MS: u32 = 250;
|
||||
let mut wedged = false;
|
||||
while let Ok(job) = work_rx.recv() {
|
||||
let wait_ms = if wedged { WEDGED_DRAIN_WAIT_MS } else { 5000 };
|
||||
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
|
||||
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
|
||||
// valid until `teardown`, which joins this thread first. `WaitForSingleObject` takes the
|
||||
@@ -334,9 +469,13 @@ fn retrieve_loop(
|
||||
// Lock/unlock from a secondary thread while the encode thread submits is the NVENC
|
||||
// guide's documented threading model.
|
||||
let result = unsafe {
|
||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), 5000) != WAIT_OBJECT_0 {
|
||||
Err("NVENC completion event timeout (5s) — encoder wedged?".to_string())
|
||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), wait_ms) != WAIT_OBJECT_0 {
|
||||
wedged = true;
|
||||
Err(format!(
|
||||
"NVENC completion event timeout ({wait_ms} ms) — encoder wedged?"
|
||||
))
|
||||
} else {
|
||||
wedged = false;
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
outputBitstream: job.bs as *mut c_void,
|
||||
@@ -387,6 +526,12 @@ pub struct NvencD3d11Encoder {
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
|
||||
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
||||
chroma_444: bool,
|
||||
/// What the SESSION NEGOTIATED, before any per-init downgrade. `chroma_444` above is the
|
||||
/// EFFECTIVE value for the session currently open and is cleared whenever the capturer hands us
|
||||
/// subsampled YUV — which used to overwrite the negotiation itself, so a capturer that went
|
||||
/// back to RGB (HDR toggle, capture-path switch) could never recover 4:4:4 for the rest of the
|
||||
/// stream. Keeping the request separate lets every re-init recompute the effective value.
|
||||
chroma_444_requested: bool,
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
|
||||
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
|
||||
yuv444_supported: bool,
|
||||
@@ -394,6 +539,17 @@ pub struct NvencD3d11Encoder {
|
||||
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
|
||||
/// (HDR can toggle mid-session); a change re-inits the session.
|
||||
hdr: bool,
|
||||
/// The HDR state the CAPTURE FORMAT asks for, independent of whether this GPU can serve it.
|
||||
///
|
||||
/// `hdr` above is the effective value and `query_caps` clears it on a card without 10-bit
|
||||
/// encode. `submit` re-derives the requested value from every frame's pixel format, so
|
||||
/// comparing that against the post-downgrade `hdr` made them disagree permanently: a P010
|
||||
/// capturer on such a GPU reported "HDR changed" on EVERY frame and tore down and rebuilt the
|
||||
/// whole session each time. The re-init trigger compares against THIS instead.
|
||||
hdr_requested: bool,
|
||||
/// Latched when `query_caps` finds no 10-bit encode support, so the downgrade is remembered
|
||||
/// across re-inits instead of being rediscovered (and re-warned) every session.
|
||||
hdr_unsupported: bool,
|
||||
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
|
||||
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
|
||||
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
|
||||
@@ -455,6 +611,11 @@ pub struct NvencD3d11Encoder {
|
||||
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
|
||||
/// tear down and re-init NVENC against it.
|
||||
init_device: *mut c_void,
|
||||
/// COM ref pinning that device while the session lives. `init_device` alone pins nothing, and
|
||||
/// `teardown` releases the texture registrations (the only other refs) BEFORE destroying the
|
||||
/// session — worse, a failed destroy parks the session handle for a later retry, which must
|
||||
/// never outlive the device it references. Moved into the [`ParkedSession`] on that path.
|
||||
init_device_com: Option<ID3D11Device>,
|
||||
/// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 2–3
|
||||
/// under forced split-encode — a split session occupies one session per engine). `0` while no
|
||||
/// session is open; set by `init_session`, returned by `teardown`.
|
||||
@@ -504,6 +665,9 @@ impl NvencD3d11Encoder {
|
||||
bit_depth,
|
||||
// 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
|
||||
chroma_444: chroma.is_444() && codec == Codec::H265,
|
||||
chroma_444_requested: chroma.is_444() && codec == Codec::H265,
|
||||
hdr_requested: false,
|
||||
hdr_unsupported: false,
|
||||
yuv444_supported: false,
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
@@ -525,6 +689,7 @@ impl NvencD3d11Encoder {
|
||||
session_async: false,
|
||||
last_rfi_range: None,
|
||||
init_device: ptr::null_mut(),
|
||||
init_device_com: None,
|
||||
session_units: 0,
|
||||
})
|
||||
}
|
||||
@@ -571,18 +736,41 @@ impl NvencD3d11Encoder {
|
||||
for &bs in &self.bitstreams {
|
||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||
}
|
||||
// A destroy failure means the driver may still hold this session's slot (the concurrent-
|
||||
// session cap is per process and only a restart clears a leak) — make it visible instead
|
||||
// of silently discarding the status.
|
||||
if let Err(e) = (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed at teardown — the driver may have leaked this \
|
||||
session's slot toward the concurrent-session cap"
|
||||
);
|
||||
// Session-budget truth (see LIVE_SESSION_UNITS): refund only on PROOF the driver released
|
||||
// the slot — a successful destroy, or a failure whose status says the session/device no
|
||||
// longer exists on the driver side (a TDR reclaims every session with the context). The
|
||||
// old unconditional refund made the counter drift low on real leaks (over-admitting
|
||||
// parallel displays); the opposite extreme — a permanent charge on ANY failure — let one
|
||||
// transient wedge episode poison admission until a host restart. Ambiguous failures
|
||||
// instead PARK the handle with its units still charged (fail-closed), and
|
||||
// `reap_parked_sessions` retries the destroy once no session is live, so the charge lasts
|
||||
// exactly as long as the driver keeps refusing — which is the definition of still-leaked.
|
||||
let dev_pin = self.init_device_com.take();
|
||||
match (api().destroy_encoder)(self.encoder).nv_ok() {
|
||||
Ok(()) => {
|
||||
LIVE_SESSION_UNITS
|
||||
.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Err(e) if nvenc_status::destroy_proves_no_session(e) => {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
"NVENC destroy_encoder failed, but the status proves the driver holds no \
|
||||
session (device gone/reset) — budget refunded"
|
||||
);
|
||||
LIVE_SESSION_UNITS
|
||||
.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
status = ?e,
|
||||
units = self.session_units,
|
||||
"NVENC destroy_encoder failed ambiguously — the driver may still hold this \
|
||||
session's slot; parking the handle (units stay charged until a reap-destroy \
|
||||
proves the slot free)"
|
||||
);
|
||||
park_session(self.encoder as usize, self.session_units, dev_pin);
|
||||
}
|
||||
}
|
||||
// Return this session's units to the budget (see LIVE_SESSION_UNITS).
|
||||
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
self.session_units = 0;
|
||||
self.regs.clear(); // drops the texture clones, releasing our refs
|
||||
self.bitstreams.clear();
|
||||
@@ -641,6 +829,9 @@ impl NvencD3d11Encoder {
|
||||
e,
|
||||
));
|
||||
}
|
||||
// The handshake with the kernel-mode driver just succeeded — from here on, an
|
||||
// `NV_ENC_ERR_INVALID_VERSION` in this process cannot be a driver version skew.
|
||||
nvenc_status::note_session_opened();
|
||||
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
|
||||
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
|
||||
let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
|
||||
@@ -668,7 +859,12 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
|
||||
if self.bit_depth >= 10 && ten_bit == 0 {
|
||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||
if !self.hdr_unsupported {
|
||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||
}
|
||||
// Latched: `submit` compares the frame's REQUESTED hdr against `hdr_requested`, not
|
||||
// against this cleared value, so a 10-bit capturer no longer re-inits every frame.
|
||||
self.hdr_unsupported = true;
|
||||
self.bit_depth = 8;
|
||||
self.hdr = false;
|
||||
}
|
||||
@@ -754,6 +950,27 @@ impl NvencD3d11Encoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
|
||||
/// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
|
||||
/// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
|
||||
/// one failed open + re-search, never a wrong session.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
let gpu = pf_gpu::resolve_render_adapter_luid()
|
||||
.map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
|
||||
.unwrap_or(0);
|
||||
CeilingKey {
|
||||
gpu,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
@@ -780,6 +997,7 @@ impl NvencD3d11Encoder {
|
||||
}
|
||||
return Err(nvenc_status::call_err("open_encode_session_ex", e));
|
||||
}
|
||||
nvenc_status::note_session_opened();
|
||||
|
||||
let mut cfg = match self.build_config(enc, bitrate) {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -812,6 +1030,15 @@ impl NvencD3d11Encoder {
|
||||
|
||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||
// Serialize this whole open sequence (caps probe + bitrate-clamp search + charge) against
|
||||
// every other open and against the zombie reap — see DRIVER_SESSION_GATE. Cold path:
|
||||
// sessions open on a stream start or a device-change rebuild, never per frame.
|
||||
let _gate = DRIVER_SESSION_GATE
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
// SAFETY: gate held (no open can be handed a recycled zombie address mid-reap) and the
|
||||
// reap itself re-checks that no live session exists before touching any parked handle.
|
||||
unsafe { reap_parked_sessions() };
|
||||
// SAFETY: every call below goes through a function pointer resolved once from the
|
||||
// runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
|
||||
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
||||
@@ -834,43 +1061,14 @@ impl NvencD3d11Encoder {
|
||||
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever
|
||||
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops
|
||||
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
|
||||
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
|
||||
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
|
||||
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
|
||||
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
|
||||
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
|
||||
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
|
||||
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
|
||||
// precedence (env override / the measured Main10 don't-split rule / pixel rate).
|
||||
// The init-failure fallback below disables it if a codec/config rejects it.
|
||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
|
||||
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
|
||||
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
|
||||
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
|
||||
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode,
|
||||
bit_depth = self.bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
||||
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
||||
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
||||
@@ -884,39 +1082,69 @@ impl NvencD3d11Encoder {
|
||||
// built in the right mode from the start.
|
||||
let use_async = self.async_supported && async_retrieve_requested();
|
||||
|
||||
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC: requested bitrate above the cached codec-level ceiling — opening \
|
||||
at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
|
||||
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
|
||||
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
|
||||
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
|
||||
// which then masqueraded as a bitrate cap and failed "even at the floor".
|
||||
// `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) {
|
||||
if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) {
|
||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
|
||||
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
||||
match self.try_open_session(device, mid, used_split, use_async) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -925,7 +1153,15 @@ impl NvencD3d11Encoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
@@ -933,14 +1169,19 @@ impl NvencD3d11Encoder {
|
||||
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
|
||||
.or_else(|_| {
|
||||
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
})
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self
|
||||
.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -948,21 +1189,22 @@ impl NvencD3d11Encoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
||||
// the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
// Pin the device for the session's lifetime — and for a parked afterlife if the
|
||||
// eventual destroy fails (see `ParkedSession::_device`).
|
||||
self.init_device_com = Some(device.clone());
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
|
||||
self.split_mode = used_split;
|
||||
self.session_async = use_async;
|
||||
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
||||
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||
// mode (a split session occupies one hardware session per engine).
|
||||
self.session_units = split_mode_units(split_mode);
|
||||
self.session_units = split_mode_units(used_split);
|
||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||
// info line for the same event here.)
|
||||
@@ -986,6 +1228,12 @@ impl NvencD3d11Encoder {
|
||||
for _ in 0..POOL {
|
||||
let ev = CreateEventW(None, false, false, PCWSTR::null())
|
||||
.context("CreateEvent (NVENC completion)")?;
|
||||
// Push BEFORE registering: a failed registration propagates out of
|
||||
// `init_session` into submit's teardown call, and only handles that made it
|
||||
// into `self.events` get closed there — pushing after the fallible call
|
||||
// leaked the Win32 event on every registration failure. Teardown's
|
||||
// unregister of the one never-registered event is a harmless error return.
|
||||
self.events.push(ev.0 as usize);
|
||||
let mut ep = nv::NV_ENC_EVENT_PARAMS {
|
||||
version: nv::NV_ENC_EVENT_PARAMS_VER,
|
||||
completionEvent: ev.0,
|
||||
@@ -994,7 +1242,6 @@ impl NvencD3d11Encoder {
|
||||
(api().register_async_event)(enc, &mut ep)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("register_async_event", e))?;
|
||||
self.events.push(ev.0 as usize);
|
||||
}
|
||||
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
||||
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
||||
@@ -1086,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let dev_raw = frame.device.as_raw();
|
||||
let size_changed =
|
||||
self.inited && (self.width != captured.width || self.height != captured.height);
|
||||
let hdr_changed = self.inited && self.hdr != hdr;
|
||||
// Compare against what was REQUESTED last init, not the effective `self.hdr`: on a GPU
|
||||
// without 10-bit encode `query_caps` clears `self.hdr`, so comparing it against a P010
|
||||
// capturer's perpetual `hdr = true` reported a change on every single frame and tore the
|
||||
// session down and rebuilt it each time.
|
||||
let hdr_changed = self.inited && self.hdr_requested != hdr;
|
||||
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
|
||||
tracing::info!(
|
||||
device_changed = self.init_device != dev_raw,
|
||||
@@ -1107,7 +1358,13 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// Adopt the current frame size + colour so the encoder always matches the capturer output.
|
||||
self.width = captured.width;
|
||||
self.height = captured.height;
|
||||
self.hdr_requested = hdr;
|
||||
// Effective until `query_caps` says otherwise (it clears this on a card without 10-bit).
|
||||
self.hdr = hdr;
|
||||
// Recompute the EFFECTIVE 4:4:4 from the negotiation on every init. This used to read
|
||||
// (and overwrite) `chroma_444` itself, so the first subsampled-YUV frame permanently
|
||||
// demoted the session — 4:4:4 never returned even when the capturer went back to RGB.
|
||||
self.chroma_444 = self.chroma_444_requested;
|
||||
// Pick the NVENC input format from the captured pixel format. YUV (NV12/P010) is the
|
||||
// video-processor path — NVENC encodes it natively (no internal RGB→YUV, which is a hidden
|
||||
// 3D/compute step that would fight a GPU-saturating game). RGB (ARGB/ABGR10) is the legacy
|
||||
@@ -1133,9 +1390,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
};
|
||||
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
||||
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
||||
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
||||
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
|
||||
// the stream really carries instead of silently claiming full chroma.
|
||||
// native YUV despite a 4:4:4 negotiation, THIS session encodes 4:2:0 — clear the
|
||||
// effective flag now so `caps().chroma_444` (and native's post-open cross-check)
|
||||
// reports what the stream really carries instead of silently claiming full chroma.
|
||||
// Only the effective value is cleared: `chroma_444_requested` keeps the negotiation, so
|
||||
// a later re-init on an RGB capture recovers 4:4:4 instead of being stuck at 4:2:0.
|
||||
if self.chroma_444
|
||||
&& !matches!(
|
||||
self.buffer_fmt,
|
||||
@@ -1181,9 +1440,23 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// despite this comment previously claiming otherwise. Since this backend encodes the
|
||||
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
|
||||
// rotate a texture out from under a live encode — torn frames, silently.
|
||||
// FAIL SAFE when nobody told us. `set_input_ring_depth` is a defaulted trait method, so a
|
||||
// caller that forgets it fails SILENTLY — and one did: the GameStream loop opens an encoder
|
||||
// (`gamestream/stream.rs`) and never calls it, while the Windows IDD-push capturer declares a
|
||||
// ring of 2 and `async_inflight_cap()` defaults to 4. That let a Moonlight session pipeline
|
||||
// four encodes against a two-texture ring, i.e. exactly the in-place overwrite this cap
|
||||
// exists to prevent, as torn/mixed frames and never an error.
|
||||
//
|
||||
// Guarding at the single point of CONSUMPTION rather than at each call site is deliberate:
|
||||
// it covers every present and future caller, including ones that have not been written yet,
|
||||
// whereas plumbing the setter into N loops only fixes the N sites someone remembered. An
|
||||
// unknown ring is treated as the shallowest one any capturer in this tree declares, so the
|
||||
// unconfigured path degrades to less pipelining — a latency cost, not corruption. Callers
|
||||
// that DO configure it are unaffected.
|
||||
const UNCONFIGURED_RING_DEPTH: usize = 2;
|
||||
let cap = match self.input_ring_depth {
|
||||
Some(d) => async_inflight_cap().min(d.max(1)),
|
||||
None => async_inflight_cap(),
|
||||
None => async_inflight_cap().min(UNCONFIGURED_RING_DEPTH),
|
||||
};
|
||||
while self.async_rt.is_some() && self.pending.len() >= cap {
|
||||
let done = {
|
||||
@@ -1380,6 +1653,8 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.rfi_supported,
|
||||
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
|
||||
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
|
||||
@@ -1404,61 +1679,56 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// No live session, the GPU can't invalidate, or a nonsense range → caller forces a full IDR.
|
||||
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
|
||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
||||
// No live session or the GPU can't invalidate → caller forces a full IDR. (NVENC handles
|
||||
// are single-threaded; this runs on the encode thread, like submit/poll.) Everything else
|
||||
// — range validity, covering-range dedup, the DPB window, the clamp — is
|
||||
// `nvenc_core::plan_range_recovery`, one policy for both direct-NVENC backends.
|
||||
if self.encoder.is_null() || !self.rfi_supported {
|
||||
return false;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — no new driver calls needed,
|
||||
// no IDR. RE-ARM the anchor though: the client re-asking means the previous recovery
|
||||
// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor
|
||||
// (it too references only valid frames).
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
|
||||
// Already invalidated a covering range for this loss event — no new driver calls
|
||||
// needed, no IDR. RE-ARM the anchor though: the client re-asking means the previous
|
||||
// recovery anchor AU may itself have been lost, and the next frame is just as clean a
|
||||
// re-anchor (it too references only valid frames).
|
||||
RangePlan::Covered => {
|
||||
self.pending_anchor = true;
|
||||
return true;
|
||||
true
|
||||
}
|
||||
}
|
||||
// `frame_idx` is the NEXT timestamp to assign, so the last encoded frame is `frame_idx - 1`
|
||||
// and the DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`. A lost frame older than that
|
||||
// can't be invalidated, so the only correct recovery is an IDR.
|
||||
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
|
||||
if first < oldest_in_dpb {
|
||||
return false;
|
||||
}
|
||||
// Clamp to frames we've actually encoded (don't invalidate a timestamp we never assigned).
|
||||
let last = last.min(self.frame_idx - 1);
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the WIRE
|
||||
// frame index the AU carries — so the client's lost-frame range maps 1:1 onto the
|
||||
// timestamps NVENC invalidates here, and stays 1:1 across encoder rebuilds/resets (an
|
||||
// internal counter would desync on the first adaptive-bitrate rebuild and RFI would then
|
||||
// clamp every range into first > last, silently degrading to IDR-only forever).
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
|
||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
||||
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
||||
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
|
||||
// lifetime concern.
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false; // any failure → fall back to IDR
|
||||
RangePlan::Decline => false,
|
||||
RangePlan::Invalidate { first, last } => {
|
||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the
|
||||
// WIRE frame index the AU carries — so the client's lost-frame range maps 1:1 onto
|
||||
// the timestamps NVENC invalidates here, and stays 1:1 across encoder
|
||||
// rebuilds/resets (an internal counter would desync on the first adaptive-bitrate
|
||||
// rebuild and RFI would then clamp every range into first > last, silently
|
||||
// degrading to IDR-only forever).
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded
|
||||
// `EncodeApi` table. `self.encoder` was checked non-null at the top of this fn and
|
||||
// is the live session; this runs on the encode thread (like submit/poll), so there
|
||||
// is no concurrent NVENC use. The plan clamped each `ts` to
|
||||
// `[oldest_in_dpb, frame_idx - 1]`, so it names a frame still in the session's
|
||||
// DPB; the call passes only that `u64` timestamp (no struct), so there is no
|
||||
// struct-size or lifetime concern.
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
.nv_ok()
|
||||
.is_err()
|
||||
{
|
||||
return false; // any failure → fall back to IDR
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the first one encoded after the invalidation — the
|
||||
// clean re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and
|
||||
// the client lifts its post-loss freeze on it (instead of waiting ~1 s for the
|
||||
// cooldown-suppressed IDR fallback).
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the first one encoded after the invalidation — the clean
|
||||
// re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and the client
|
||||
// lifts its post-loss freeze on it (instead of waiting ~1 s for the cooldown-suppressed
|
||||
// IDR fallback).
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
@@ -1552,6 +1822,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
||||
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
||||
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
||||
@@ -1600,6 +1879,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -1645,6 +1930,11 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||
/// capability that couldn't be confirmed.
|
||||
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
// Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
|
||||
// overlap a zombie reap that could be destroying the very address the driver hands us.
|
||||
let _gate = DRIVER_SESSION_GATE
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
|
||||
@@ -1723,6 +2013,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Availability probe, but a real session open all the same: it proves the driver accepted
|
||||
// this build's version word, which is what rules a skew out later (see `nvenc_status`).
|
||||
nvenc_status::note_session_opened();
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: cap,
|
||||
|
||||
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
|
||||
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
|
||||
// slips through (e.g. the lab override) rather than wrap the RDO block index.
|
||||
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
|
||||
// twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
|
||||
// downgrade hands oversized modes to this open AS 4:2:0, so the old `chroma444`-gated
|
||||
// check was skipped exactly when it was needed.
|
||||
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
|
||||
bail!(
|
||||
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
|
||||
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
|
||||
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
|
||||
index (see pyrowave-sys patches/0002 note) — lower the resolution",
|
||||
if chroma444 { "4:4:4" } else { "4:2:0" }
|
||||
);
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
@@ -294,8 +297,10 @@ impl PyroWaveEncoder {
|
||||
/// Import one capturer plane D3D11 texture (`R8_UNORM` Y or `R8G8_UNORM` CbCr) into pyrowave's
|
||||
/// Vulkan device. Creates a fresh shared NT handle from the texture (the capturer marked the ring
|
||||
/// `SHARED | SHARED_NTHANDLE`); `pyrowave_image_create` takes ownership of the handle and closes
|
||||
/// it on import. Single/two-component textures import reliably on NVIDIA at any size — unlike a
|
||||
/// planar NV12 — so no MUTABLE_FORMAT / planar-layout workaround is involved.
|
||||
/// it on SUCCESSFUL import only (pyrowave-sys patch 0006 — same contract as the fence import in
|
||||
/// [`Self::import_fence`]), so this fn closes it on every failure return. Single/two-component
|
||||
/// textures import reliably on NVIDIA at any size — unlike a planar NV12 — so no
|
||||
/// MUTABLE_FORMAT / planar-layout workaround is involved.
|
||||
///
|
||||
/// # Safety
|
||||
/// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created
|
||||
@@ -346,7 +351,11 @@ impl PyroWaveEncoder {
|
||||
};
|
||||
let mut image: pw::pyrowave_image = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(pw::pyrowave_image_create(&info, &mut image), "image_create") {
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close it ourselves on failure.
|
||||
// pyrowave consumes the handle ONLY on a successful import (pyrowave-sys patch 0006
|
||||
// pinned this at the API's success boundary) — so on EVERY failure return the handle
|
||||
// is still ours and this close is the single one. Before the patch, an
|
||||
// allocate-stage failure inside Granite had already closed it and this was a double
|
||||
// close of a possibly-recycled handle value.
|
||||
let _ = CloseHandle(handle);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -416,7 +425,9 @@ impl PyroWaveEncoder {
|
||||
pw::pyrowave_sync_object_create(&info, &mut sync),
|
||||
"sync_object_create",
|
||||
) {
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close the dup on failure.
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import (Granite's semaphore import
|
||||
// has always had these semantics; patch 0006 made the image import match) — close
|
||||
// the dup on failure.
|
||||
let _ = CloseHandle(dup);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -678,6 +689,8 @@ impl Encoder for PyroWaveEncoder {
|
||||
// after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4
|
||||
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
|
||||
EncoderCaps {
|
||||
// The Windows capturer composites the pointer itself; this backend never reads it.
|
||||
blends_cursor: false,
|
||||
chroma_444: self.chroma444,
|
||||
..EncoderCaps::default()
|
||||
}
|
||||
|
||||
@@ -519,9 +519,20 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
}
|
||||
b.WhitePointX = m.white_point[0];
|
||||
b.WhitePointY = m.white_point[1];
|
||||
// Units diverge on the max: VPL wants whole cd/m² (HdrMeta carries 0.0001 cd/m²); the
|
||||
// min is 0.0001 cd/m² on both sides.
|
||||
b.MaxDisplayMasteringLuminance = m.max_display_mastering_luminance / 10_000;
|
||||
// BOTH luminance fields are 0.0001 cd/m² here — do NOT scale the max.
|
||||
//
|
||||
// The `/10_000` this used to carry followed the VPL header's *video-processing* unit
|
||||
// (whole cd/m², which is right for VPP), but on the ENCODE path the runtime passes these
|
||||
// straight into the ITU-T H.265 Annex D mastering-display SEI, whose unit is 0.0001 cd/m².
|
||||
// Dividing therefore under-reported peak brightness by 10,000x. Confirmed in a real
|
||||
// bitstream on Intel UHD 750 (2026-07-25): SEI 137 carried
|
||||
// max_display_mastering_luminance = 1000, i.e. **0.1 nits** for a 1000-nit display, while
|
||||
// the undivided min = 500 (0.05 nits) was correct — the asymmetry was the tell. A client
|
||||
// tone-mapping against 0.1 nits crushes the image.
|
||||
//
|
||||
// Unscaled also matches every sibling: `MinDisplayMasteringLuminance` right below,
|
||||
// `pf_frame::hdr::hevc_mastering_display_sei`, and the AMF backend.
|
||||
b.MaxDisplayMasteringLuminance = m.max_display_mastering_luminance;
|
||||
b.MinDisplayMasteringLuminance = m.min_display_mastering_luminance;
|
||||
b
|
||||
});
|
||||
@@ -690,13 +701,17 @@ impl Inner {
|
||||
}
|
||||
|
||||
fn take_bs(&mut self) -> Box<BsBuf> {
|
||||
match self.bs_pool.pop() {
|
||||
Some(mut b) => {
|
||||
// Pooled buffers were sized by whatever `bs_bytes` was when they were allocated. A bitrate
|
||||
// retarget raises the driver's worst-case AU size, so a recycled buffer can be SMALLER than
|
||||
// the current requirement — hand those back to the allocator instead of letting the runtime
|
||||
// write an AU into a short buffer.
|
||||
while let Some(mut b) = self.bs_pool.pop() {
|
||||
if b.mfx.MaxLength as usize >= self.bs_bytes {
|
||||
b.recycle();
|
||||
b
|
||||
return b;
|
||||
}
|
||||
None => BsBuf::new(self.bs_bytes),
|
||||
}
|
||||
BsBuf::new(self.bs_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +789,12 @@ impl QsvEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe");
|
||||
}
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||
// capture derived `expected = P010` and hit the bail below, ending the session at open —
|
||||
// and taking the ffmpeg fallback with it, since that path had the same defect in a worse
|
||||
// form (it accepted the open and then failed every frame).
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
if ten_bit && codec == Codec::H264 {
|
||||
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
|
||||
}
|
||||
@@ -878,7 +898,8 @@ impl QsvEncoder {
|
||||
if sts > vpl::MFX_ERR_NONE {
|
||||
tracing::debug!(status = sts_name(sts), "QSV Init returned a warning");
|
||||
}
|
||||
let ir_active = cfg.intra_refresh && set.co2.is_some();
|
||||
// Whether we ASKED for intra-refresh. Not the same as getting it — confirmed below.
|
||||
let ir_requested = cfg.intra_refresh && set.co2.is_some();
|
||||
// The driver's own answer for the worst-case AU size.
|
||||
// SAFETY: `session` is live; `got` and its (empty) ext chain outlive the call.
|
||||
let bs_bytes = unsafe {
|
||||
@@ -892,6 +913,57 @@ impl QsvEncoder {
|
||||
let kb = enc_of(m).BufferSizeInKB as usize;
|
||||
(kb * mult * 1000).max(256 * 1024)
|
||||
};
|
||||
// Intra-refresh HONESTY: ask the driver what it actually installed instead of trusting that
|
||||
// it took what we sent. Both `Query` and `Init` can return MFX_WRN_INCOMPATIBLE_VIDEO_PARAM
|
||||
// — a WARNING, which the code above accepts — while silently dropping the wave. Confirmed
|
||||
// on Intel UHD 750 (2026-07-25): H.264 reports exactly that, `GetVideoParam` comes back with
|
||||
// `IntRefType = 0`, and `ir_active` still claimed true. H.265 on the same GPU is genuinely
|
||||
// active, so this is per-codec and cannot be decided statically.
|
||||
//
|
||||
// That lie is not cosmetic: `ir_active` feeds `EncoderCaps::intra_refresh`, so the session
|
||||
// advertises gradual refresh to the client and then never emits it — the client stops
|
||||
// requesting the IDRs it would otherwise have asked for on loss, and a lost frame is
|
||||
// concealed instead of repaired.
|
||||
//
|
||||
// Deliberately a SEPARATE, best-effort query rather than a buffer chained onto the
|
||||
// `BufferSizeInKB` call above (which is what the audit finding suggested): that value is
|
||||
// load-bearing for every bitstream allocation, and a runtime that dislikes the chained
|
||||
// buffer would take it down with the readback. A failure here costs only the verdict, and
|
||||
// is resolved conservatively.
|
||||
let ir_active = if ir_requested {
|
||||
// SAFETY: `session` is live on this thread; `got` and `co2_out` (with `ptrs` holding the
|
||||
// only reference to it) all outlive the synchronous call, and the runtime writes back
|
||||
// only into the buffer whose header we stamped.
|
||||
let confirmed = unsafe {
|
||||
let mut got: vpl::mfxVideoParam = std::mem::zeroed();
|
||||
let mut co2_out: vpl::mfxExtCodingOption2 = std::mem::zeroed();
|
||||
co2_out.Header.BufferId = vpl::MFX_EXTBUFF_CODING_OPTION2 as u32;
|
||||
co2_out.Header.BufferSz = std::mem::size_of::<vpl::mfxExtCodingOption2>() as u32;
|
||||
let mut ptrs: [*mut vpl::mfxExtBuffer; 1] = [&mut co2_out.Header as *mut _];
|
||||
got.ExtParam = ptrs.as_mut_ptr();
|
||||
got.NumExtParam = 1;
|
||||
let sts = vpl::MFXVideoENCODE_GetVideoParam(session, &mut got);
|
||||
if sts < vpl::MFX_ERR_NONE {
|
||||
tracing::debug!(
|
||||
status = sts_name(sts),
|
||||
"QSV: could not read back CodingOption2 — trusting the intra-refresh request"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
co2_out.IntRefType != 0
|
||||
}
|
||||
};
|
||||
if !confirmed {
|
||||
tracing::warn!(
|
||||
codec = ?cfg.codec,
|
||||
"QSV silently dropped intra-refresh (GetVideoParam reports IntRefType=0) — \
|
||||
advertising it OFF so the client keeps asking for IDRs on loss"
|
||||
);
|
||||
}
|
||||
confirmed
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok((ltr_active, ir_active, bs_bytes))
|
||||
}
|
||||
|
||||
@@ -1344,36 +1416,30 @@ impl Encoder for QsvEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
||||
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
||||
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
//
|
||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
||||
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
|
||||
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
|
||||
// could still predict from it. With two slots the "exactly one swept" case is the modal
|
||||
// one, and it was the broken one.
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
self.ltr_tainted[slot] = true;
|
||||
// The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
|
||||
// shared with AMF and Vulkan Video). This backend's mechanism: distrust is a SEPARATE
|
||||
// `ltr_tainted` flag, never a cleared mirror slot — `ltr_slots` mirrors the HARDWARE DPB,
|
||||
// and nulling an entry issues no VPL call, so the frame stays marked long-term in the
|
||||
// encoder. Clearing it made the rejection list below (which iterates the mirror and only
|
||||
// names `Some` slots) silently SKIP the one entry the sweep exists to distrust, so the
|
||||
// recovery frame could still predict from it. With two slots the "exactly one swept" case
|
||||
// is the modal one, and it was the broken one. The `!ltr_tainted` view filter below is
|
||||
// what persists that distrust across loss events (this call's taints are excluded from
|
||||
// this call's anchor by the policy itself).
|
||||
let view: Vec<(usize, i64)> = self
|
||||
.ltr_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(slot, _)| !self.ltr_tainted[slot])
|
||||
.filter_map(|(s, m)| m.map(|w| (s, w)))
|
||||
.collect();
|
||||
let plan = super::rfi::plan_slot_recovery(&view, first);
|
||||
for (slot, tainted) in self.ltr_tainted.iter_mut().enumerate() {
|
||||
if plan.tainted & (1 << slot) != 0 {
|
||||
*tainted = true;
|
||||
}
|
||||
}
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if self.ltr_tainted[slot] {
|
||||
continue; // still in the DPB, but encoded inside the corrupt window
|
||||
}
|
||||
if let Some(idx) = *marked {
|
||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||
best = Some((slot, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
match plan.anchor {
|
||||
Some((slot, ltr_frame)) => {
|
||||
self.pending_force = Some(slot);
|
||||
tracing::info!(
|
||||
@@ -1402,6 +1468,8 @@ impl Encoder for QsvEncoder {
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
|
||||
blends_cursor: false,
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
|
||||
// are never HDR.
|
||||
@@ -1576,6 +1644,38 @@ impl Encoder for QsvEncoder {
|
||||
self.bitrate_bps = old;
|
||||
return false;
|
||||
}
|
||||
// Re-read the driver's worst-case AU size. `Reset` re-derives `BufferSizeInKB` from the new
|
||||
// rate, and a step-up can raise it well above what `init_encode` computed — leaving
|
||||
// `bs_bytes` (and every buffer already in the pool) sized for the OLD, lower bitrate. This
|
||||
// mirrors `init_encode`, which asks the same question post-Init; `take_bs` drops any pooled
|
||||
// buffer that is now too small.
|
||||
// SAFETY: `session` is live on this thread and drained above; `got` and its (empty) ext
|
||||
// chain outlive the synchronous call.
|
||||
let refreshed = unsafe {
|
||||
let mut got: vpl::mfxVideoParam = std::mem::zeroed();
|
||||
let sts = vpl::MFXVideoENCODE_GetVideoParam(session, &mut got);
|
||||
(sts >= vpl::MFX_ERR_NONE).then(|| {
|
||||
let m = &mut got.__bindgen_anon_1.mfx;
|
||||
let mult = m.BRCParamMultiplier.max(1) as usize;
|
||||
let kb = enc_of(m).BufferSizeInKB as usize;
|
||||
(kb * mult * 1000).max(256 * 1024)
|
||||
})
|
||||
};
|
||||
match (refreshed, self.inner.as_mut()) {
|
||||
(Some(bytes), Some(inner)) if bytes > inner.bs_bytes => {
|
||||
tracing::debug!(
|
||||
old_bytes = inner.bs_bytes,
|
||||
new_bytes = bytes,
|
||||
mbps = bps / 1_000_000,
|
||||
"QSV retarget raised the worst-case AU size — resizing the bitstream pool"
|
||||
);
|
||||
inner.bs_bytes = bytes;
|
||||
}
|
||||
(None, _) => tracing::warn!(
|
||||
"QSV retarget: GetVideoParam failed, keeping the previous AU buffer size"
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
+616
-234
@@ -5,9 +5,11 @@
|
||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
||||
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
||||
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
||||
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that
|
||||
// uses them (mirrors the host crate root's allow before the extraction).
|
||||
#![allow(dead_code)]
|
||||
// NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
|
||||
// root as scaffolding for backend paths defined ahead of the build that used them, but a census
|
||||
// across every feature combination on both platforms found it was hiding exactly two items — so it
|
||||
// bought nothing and blinded the crate to future rot. Genuinely test-only helpers carry
|
||||
// `#[cfg(test)]` instead.
|
||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
@@ -39,11 +41,14 @@ impl Codec {
|
||||
// per-session instead (the host `session_plan::SessionPlan::output_format`) — the EGL→CUDA
|
||||
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
||||
// keeps the bit off (no Vulkan device to open).
|
||||
// Resolved ONCE for every gate below (pyro bit, software pin, vulkan ceiling, the
|
||||
// zero-copy plane): this fn backs the codec advertisement on polled paths, and the
|
||||
// resolver's auto arm samples live GPU-preference state — four samples per call was the
|
||||
// probe-amplification the WP7.6 diff critic caught.
|
||||
#[cfg(target_os = "linux")]
|
||||
let backend = linux_resolved_backend();
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let pyro = if !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
let pyro = if backend != LinuxBackend::Software {
|
||||
punktfunk_core::quic::CODEC_PYROWAVE
|
||||
} else {
|
||||
0u8
|
||||
@@ -70,19 +75,41 @@ impl Codec {
|
||||
| punktfunk_core::quic::CODEC_AV1;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
if backend == LinuxBackend::Software {
|
||||
return punktfunk_core::quic::CODEC_H264;
|
||||
}
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
// A pref that FORCES the raw Vulkan Video backend can only ever serve what that
|
||||
// backend encodes: `open_video_backend`'s `vulkan` arm bails outright for anything
|
||||
// that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the session
|
||||
// negotiated {codec:?}"). Advertising H.264 there let a client negotiate it and die
|
||||
// at encoder open. Without the `vulkan-encode` feature that arm cannot open at all,
|
||||
// so the pref advertises nothing.
|
||||
//
|
||||
// This is a CEILING intersected with the device probe below, never a replacement
|
||||
// for it: pinning a static HEVC|AV1 would ADD AV1 on the AMD/Intel hosts whose probe
|
||||
// currently withholds it (pre-RDNA3, pre-Arc), i.e. it would re-create this very bug
|
||||
// for a different codec. Intersecting can only narrow.
|
||||
let pref_ceiling: u8 = match backend {
|
||||
// Feature-blindness rider (WP7.6 critics): the resolver knows the PREF is
|
||||
// vulkan; only this cfg! knows the BUILD can open it. Dropping the inner
|
||||
// branch would advertise HEVC|AV1 on a featureless build — the
|
||||
// advertise-then-die-at-open bug this ceiling exists to prevent.
|
||||
LinuxBackend::Vulkan => {
|
||||
if cfg!(feature = "vulkan-encode") {
|
||||
punktfunk_core::quic::CODEC_HEVC | punktfunk_core::quic::CODEC_AV1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ => GPU_SUPERSET,
|
||||
};
|
||||
if linux_zero_copy_is_vaapi_for(backend) {
|
||||
if let Some(m) = vaapi_codec_support().wire_mask() {
|
||||
return m;
|
||||
return m & pref_ceiling;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||||
GPU_SUPERSET
|
||||
GPU_SUPERSET & pref_ceiling
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -175,6 +202,23 @@ pub fn open_video(
|
||||
},
|
||||
}
|
||||
};
|
||||
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||||
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
|
||||
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
|
||||
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
|
||||
//
|
||||
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
|
||||
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
|
||||
// the only layer that can fall back to capturer-side compositing. This makes the condition
|
||||
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
|
||||
if cursor_blend && !inner.caps().blends_cursor {
|
||||
tracing::warn!(
|
||||
backend,
|
||||
"session negotiated a composited cursor but this encode backend does not blend \
|
||||
CapturedFrame::cursor — the pointer will be MISSING from the stream unless the \
|
||||
capturer composites it"
|
||||
);
|
||||
}
|
||||
Ok(Box::new(TrackedEncoder {
|
||||
inner,
|
||||
_session: pf_gpu::session_begin(gpu),
|
||||
@@ -241,6 +285,12 @@ impl Encoder for TrackedEncoder {
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
self.inner.reconfigure_bitrate(bps)
|
||||
}
|
||||
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
|
||||
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
|
||||
// requested rate even where NVENC clamped to the codec-level ceiling.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
self.inner.applied_bitrate_bps()
|
||||
}
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
@@ -253,6 +303,224 @@ impl Encoder for TrackedEncoder {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||
|
||||
/// The Linux half of [`open_video_backend`], with the pref INJECTED — the seam exists so the
|
||||
/// dispatch is testable without mutating process env (`set_var` races `getenv` in parallel
|
||||
/// tests) or fighting the once-latched `pf_host_config::config()`. Production takes the
|
||||
/// config pref via the caller; shared validation (dims/fps/chroma degrade) stays there too.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_video_backend_linux(
|
||||
pref: &str,
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
"session negotiated PyroWave but this host was built without --features \
|
||||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||||
);
|
||||
}
|
||||
// Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged);
|
||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
codec = ?codec,
|
||||
"Linux Vulkan Video encode (real RFI via DPB reference slots) — \
|
||||
set PUNKTFUNK_VULKAN_ENCODE=0 for libav VAAPI"
|
||||
);
|
||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||
}
|
||||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
||||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
||||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
||||
Err(e) if format == PixelFormat::Nv12 => {
|
||||
return Err(e.context(
|
||||
"Vulkan Video open failed on a native-NV12 capture \
|
||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||||
restore the packed-RGB negotiation",
|
||||
));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||||
if format == PixelFormat::Nv12 {
|
||||
anyhow::bail!(
|
||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||||
the packed-RGB negotiation"
|
||||
);
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vaapi"))
|
||||
};
|
||||
let open_nvidia = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
)
|
||||
.map(|e| (e, "nvenc"))
|
||||
};
|
||||
// WP7.6: the dispatch consumes the SAME resolver the capability mirrors consult (the
|
||||
// Windows shape — `windows_resolved_backend` + `match backend` below) so the alias table
|
||||
// exists once. Arm bodies and their open-site labels are untouched.
|
||||
match resolve_linux_backend(pref, linux_auto_is_vaapi, cuda) {
|
||||
Some(LinuxBackend::Nvenc) => open_nvidia(),
|
||||
Some(LinuxBackend::AmdIntel) => open_amd_intel(),
|
||||
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
||||
Some(LinuxBackend::Vulkan) => {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
anyhow::bail!(
|
||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||
);
|
||||
}
|
||||
vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = (format, bit_depth, chroma);
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=vulkan requires a build with --features vulkan-encode"
|
||||
)
|
||||
}
|
||||
}
|
||||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec. Explicit-only, and
|
||||
// EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2): no shipping
|
||||
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
||||
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
||||
// codec — every AU is an independently-decodable wavelet frame.
|
||||
Some(LinuxBackend::Pyrowave) => {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"PUNKTFUNK_ENCODER=pyrowave forces the all-intra wavelet stream \
|
||||
regardless of the negotiated codec — only a pyrowave-feature client \
|
||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||
normal sessions negotiate it instead)"
|
||||
);
|
||||
// The lab override forces the wavelet stream onto a session negotiated for
|
||||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
||||
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
|
||||
pyrowave::PyroWaveEncoder::open(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=pyrowave requires a build with --features punktfunk-host/pyrowave"
|
||||
)
|
||||
}
|
||||
}
|
||||
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
|
||||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||||
// RGB frame, which the capturer delivers because the software backend resolves `gpu=false`.
|
||||
Some(LinuxBackend::Software) => {
|
||||
if codec != Codec::H264 {
|
||||
anyhow::bail!(
|
||||
"the software encoder emits H.264 only; the session negotiated {codec:?} \
|
||||
(a client must advertise CODEC_H264 to reach a software host)"
|
||||
);
|
||||
}
|
||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
// The auto arm lives in the resolver now (a CUDA frame can only be consumed by
|
||||
// NVENC; else the shared manual-preference/NVIDIA-presence decision).
|
||||
None => anyhow::bail!(
|
||||
"unknown PUNKTFUNK_ENCODER={pref:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||||
@@ -295,209 +563,19 @@ fn open_video_backend(
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
"session negotiated PyroWave but this host was built without --features \
|
||||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||||
);
|
||||
}
|
||||
// Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged);
|
||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = pf_host_config::config().encoder_pref.as_str();
|
||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
codec = ?codec,
|
||||
"Linux Vulkan Video encode (real RFI via DPB reference slots) — \
|
||||
set PUNKTFUNK_VULKAN_ENCODE=0 for libav VAAPI"
|
||||
);
|
||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||
}
|
||||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
||||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
||||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
||||
Err(e) if format == PixelFormat::Nv12 => {
|
||||
return Err(e.context(
|
||||
"Vulkan Video open failed on a native-NV12 capture \
|
||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||||
restore the packed-RGB negotiation",
|
||||
));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||||
if format == PixelFormat::Nv12 {
|
||||
anyhow::bail!(
|
||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||||
the packed-RGB negotiation"
|
||||
);
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vaapi"))
|
||||
};
|
||||
let open_nvidia = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
)
|
||||
.map(|e| (e, "nvenc"))
|
||||
};
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvidia(),
|
||||
"vaapi" | "amd" | "intel" => open_amd_intel(),
|
||||
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
||||
"vulkan" | "vulkan-video" => {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
anyhow::bail!(
|
||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||
);
|
||||
}
|
||||
vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = (format, bit_depth, chroma);
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=vulkan requires a build with --features vulkan-encode"
|
||||
)
|
||||
}
|
||||
}
|
||||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec. Explicit-only, and
|
||||
// EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2): no shipping
|
||||
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
||||
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
||||
// codec — every AU is an independently-decodable wavelet frame.
|
||||
"pyrowave" => {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"PUNKTFUNK_ENCODER=pyrowave forces the all-intra wavelet stream \
|
||||
regardless of the negotiated codec — only a pyrowave-feature client \
|
||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||
normal sessions negotiate it instead)"
|
||||
);
|
||||
// The lab override forces the wavelet stream onto a session negotiated for
|
||||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
||||
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
|
||||
pyrowave::PyroWaveEncoder::open(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=pyrowave requires a build with --features punktfunk-host/pyrowave"
|
||||
)
|
||||
}
|
||||
}
|
||||
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
|
||||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||||
// RGB frame, which the capturer delivers because the software backend resolves `gpu=false`.
|
||||
"software" | "sw" | "openh264" => {
|
||||
if codec != Codec::H264 {
|
||||
anyhow::bail!(
|
||||
"the software encoder emits H.264 only; the session negotiated {codec:?} \
|
||||
(a client must advertise CODEC_H264 to reach a software host)"
|
||||
);
|
||||
}
|
||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
"auto" | "" => {
|
||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the
|
||||
// backend — see `linux_auto_is_vaapi`.
|
||||
if cuda || !linux_auto_is_vaapi() {
|
||||
open_nvidia()
|
||||
} else {
|
||||
open_amd_intel()
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
||||
),
|
||||
}
|
||||
open_video_backend_linux(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -611,8 +689,17 @@ fn open_video_backend(
|
||||
// Phase 4 after the field-silence gate.
|
||||
#[cfg(feature = "qsv")]
|
||||
{
|
||||
let ffmpeg_forced = std::env::var("PUNKTFUNK_QSV_FFMPEG")
|
||||
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
|
||||
// Trimmed like every sibling knob, so `"1 "` from a shell script or a `.env`
|
||||
// line takes effect instead of silently doing nothing. Case-insensitivity is
|
||||
// kept deliberately — this knob already accepted `TRUE` via
|
||||
// `eq_ignore_ascii_case`, and the bare house `matches!` would have dropped
|
||||
// that spelling; widening to `yes`/`on` only adds accepted values.
|
||||
let ffmpeg_forced = std::env::var("PUNKTFUNK_QSV_FFMPEG").is_ok_and(|v| {
|
||||
matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
});
|
||||
if !ffmpeg_forced {
|
||||
match qsv::QsvEncoder::open(
|
||||
codec,
|
||||
@@ -853,18 +940,31 @@ fn vulkan_encode_enabled() -> bool {
|
||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||
/// escapes at the capture gate).
|
||||
///
|
||||
/// This verdict is load-bearing in a way the other capability helpers are not: once the producer
|
||||
/// has been asked for two-plane NV12 there is **no fallback**. [`open_video`] deliberately makes a
|
||||
/// failed Vulkan open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
|
||||
/// would import that buffer as packed RGB and stream silent garbage. So a wrong `true` here does
|
||||
/// not cost quality — it kills the session at its first frame. Hence the real device probe below,
|
||||
/// and hence the conjuncts are ordered cheapest-first so it only runs when everything else already
|
||||
/// said yes.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
||||
&& !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
||||
)
|
||||
// Which backend this host actually resolves to. This used to be a denylist of the
|
||||
// EXPLICIT prefs that skip Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which
|
||||
// silently missed the one that matters: the DEFAULT `encoder_pref` is `""`, and `""`
|
||||
// resolves to `auto`, which on an NVIDIA box opens NVENC. So a stock NVIDIA host passed
|
||||
// this gate. `linux_zero_copy_is_vaapi` layers the pref on top of the same auto decision
|
||||
// `open_video` makes, which is exactly what the note on `linux_auto_is_vaapi` says a
|
||||
// capability probe must consult — and it is what the downstream consumer of this very
|
||||
// verdict already uses to pick its zero-copy path.
|
||||
&& linux_zero_copy_is_vaapi()
|
||||
// …and only then ask the GPU. Ordered last on purpose: it opens a Vulkan instance.
|
||||
&& vulkan_encode_available(codec)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
@@ -873,6 +973,42 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||
///
|
||||
/// Only [`linux_native_nv12_ok`] consults this, and only for the no-fallback decision described
|
||||
/// there. It is deliberately NOT wired into [`open_video`]'s dispatch: that path already degrades
|
||||
/// to VAAPI on a failed open for every non-NV12 capture, so making it pay for a probe would buy
|
||||
/// nothing. Prediction and truth stay separate — the probe answers "would it open", the open itself
|
||||
/// reports what actually did.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_available(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
let key = (pf_gpu::selection_key(), codec.label());
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||
return *v;
|
||||
}
|
||||
let verdict = vulkan_video::probe_encode_support(codec);
|
||||
let ok = verdict.is_ok();
|
||||
match &verdict {
|
||||
Ok(()) => tracing::info!(
|
||||
?codec,
|
||||
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
|
||||
),
|
||||
Err(why) => tracing::info!(
|
||||
?codec,
|
||||
why = *why,
|
||||
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
|
||||
(the native-NV12 path has no VAAPI fallback)"
|
||||
),
|
||||
}
|
||||
cache.lock().unwrap().insert(key, ok);
|
||||
ok
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
@@ -904,6 +1040,67 @@ fn linux_auto_is_vaapi() -> bool {
|
||||
!nvidia_present()
|
||||
}
|
||||
|
||||
/// The resolved Linux encode backend — the Linux twin of [`WindowsBackend`] (WP7.6). ONE alias
|
||||
/// table, consumed by BOTH [`open_video_backend`]'s dispatch and every capability/advertisement
|
||||
/// mirror below, so the two can never drift again (the audit counted five partial hand-copies of
|
||||
/// this decision). Labels still come from the open sites (the mgmt-record convention) — this
|
||||
/// resolves which ARM runs, never what actually opened.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum LinuxBackend {
|
||||
Nvenc,
|
||||
AmdIntel,
|
||||
Vulkan,
|
||||
Pyrowave,
|
||||
Software,
|
||||
}
|
||||
|
||||
/// The pure core. `None` = the pref names no backend — [`open_video_backend`] BAILS on that,
|
||||
/// while the capability mirrors historically resolved it as auto (via
|
||||
/// [`linux_resolved_backend`]); that divergence is recorded, not accidental. `auto_is_vaapi` is
|
||||
/// deliberately LAZY: explicit prefs must stay zero-probe — `/serverinfo` polls through the
|
||||
/// mirrors, and `gamestream/serverinfo.rs` already litigated exactly that probe cost.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve_linux_backend(
|
||||
pref: &str,
|
||||
auto_is_vaapi: impl FnOnce() -> bool,
|
||||
cuda: bool,
|
||||
) -> Option<LinuxBackend> {
|
||||
Some(match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => LinuxBackend::Nvenc,
|
||||
"vaapi" | "amd" | "intel" => LinuxBackend::AmdIntel,
|
||||
"vulkan" | "vulkan-video" => LinuxBackend::Vulkan,
|
||||
"pyrowave" => LinuxBackend::Pyrowave,
|
||||
"software" | "sw" | "openh264" => LinuxBackend::Software,
|
||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the backend —
|
||||
// see `linux_auto_is_vaapi`.
|
||||
"auto" | "" => {
|
||||
if cuda || !auto_is_vaapi() {
|
||||
LinuxBackend::Nvenc
|
||||
} else {
|
||||
LinuxBackend::AmdIntel
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Config-reading wrapper for the capability/advertisement mirrors: an unknown pref resolves as
|
||||
/// auto — exactly what every mirror's old `_` arm did. `cuda = false`: the mirrors answer
|
||||
/// pre-session questions (a CUDA payload exists only inside a live NVIDIA session).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_resolved_backend() -> LinuxBackend {
|
||||
let pref = pf_host_config::config().encoder_pref.as_str();
|
||||
resolve_linux_backend(pref, linux_auto_is_vaapi, false).unwrap_or_else(|| {
|
||||
if linux_auto_is_vaapi() {
|
||||
LinuxBackend::AmdIntel
|
||||
} else {
|
||||
LinuxBackend::Nvenc
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The dmabuf modifiers the PyroWave encoder's Vulkan device imports for the capture's
|
||||
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
||||
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
||||
@@ -917,13 +1114,25 @@ pub fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
match pf_host_config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => false,
|
||||
"vaapi" | "amd" | "intel" => true,
|
||||
linux_zero_copy_is_vaapi_for(linux_resolved_backend())
|
||||
}
|
||||
/// The zero-copy-plane decision for an ALREADY-resolved backend — so a caller that has just
|
||||
/// resolved (e.g. `host_wire_caps`, which consults several gates per call on a POLLED endpoint)
|
||||
/// pays for the resolution once instead of once per gate (the `serverinfo.rs` probe-cost class,
|
||||
/// caught by the WP7.6 diff critic).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_zero_copy_is_vaapi_for(backend: LinuxBackend) -> bool {
|
||||
match backend {
|
||||
LinuxBackend::Nvenc => false,
|
||||
LinuxBackend::AmdIntel => true,
|
||||
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
||||
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
|
||||
"pyrowave" => true,
|
||||
_ => linux_auto_is_vaapi(),
|
||||
LinuxBackend::Pyrowave => true,
|
||||
// EXACTLY the old `_` fallthrough for these two prefs — preserved, not endorsed. The
|
||||
// vulkan-on-NVIDIA half is FILED (EGL→CUDA capture negotiated for a dmabuf-importing
|
||||
// backend; reachable only via the explicit lab pref); the software half is latent (a
|
||||
// software host pins H.264, so the GPU-backend probes this steers never negotiate).
|
||||
LinuxBackend::Vulkan | LinuxBackend::Software => linux_auto_is_vaapi(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,9 +1425,17 @@ pub fn windows_resolved_backend() -> WindowsBackend {
|
||||
pub fn resolved_backend_is_gpu() -> bool {
|
||||
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||||
}
|
||||
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
||||
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
/// Linux: every backend but the GPU-less software encoder (openh264) is GPU-resident. Resolver-
|
||||
/// backed (WP7.6); cross-crate mirrors still exist in `session_plan::resolve_encoder` and
|
||||
/// `gamestream/serverinfo.rs` — out of this crate's reach, listed in the WP7.6 design doc.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolved_backend_is_gpu() -> bool {
|
||||
linux_resolved_backend() != LinuxBackend::Software
|
||||
}
|
||||
/// Other non-Windows targets (the macOS dev host): keep the string gate — no resolver exists
|
||||
/// there. `not(any(...))`, NEVER a target list, so no exotic target loses the fn (the
|
||||
/// `can_encode_444`/`can_encode_10bit` shape).
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub fn resolved_backend_is_gpu() -> bool {
|
||||
!matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
@@ -1393,7 +1610,9 @@ mod nvenc;
|
||||
#[path = "enc/linux/nvenc_cuda.rs"]
|
||||
mod nvenc_cuda;
|
||||
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
||||
// session open logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
||||
// session open names its real cause instead of the old misleading "(no NVIDIA GPU?)" — including
|
||||
// splitting the two opposite failures the driver reports as the SAME `INVALID_VERSION` (a genuine
|
||||
// driver skew vs. this process's driver state going bad after a session already opened).
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||
#[path = "enc/nvenc_status.rs"]
|
||||
mod nvenc_status;
|
||||
@@ -1402,6 +1621,20 @@ mod nvenc_status;
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||||
#[path = "enc/nvenc_core.rs"]
|
||||
mod nvenc_core;
|
||||
// The slot-family RFI recovery policy (WP7.2) shared by native AMF, native QSV and Vulkan Video —
|
||||
// the taint sweep + pre-loss anchor pick extracted from three hand-copies that had already
|
||||
// diverged once (the fecbec2d sweep predates the Vulkan backend's carve-out and was never ported
|
||||
// to it until a later fix). Policy only: every mechanism (LTR bitfield, RefListCtrl, DPB slot
|
||||
// table) stays in its backend. cfg = the union of the callers': amf.rs is featureless on Windows,
|
||||
// vulkan_video needs `vulkan-encode` on Linux — and each ITEM inside is live under the module's
|
||||
// whole cfg because `plan_slot_recovery` delegates to `pick_anchor` (dead_code is an item lint;
|
||||
// module-granular reasoning is how a Windows-dead helper reaches main).
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "linux", feature = "vulkan-encode")
|
||||
))]
|
||||
#[path = "enc/rfi.rs"]
|
||||
mod rfi;
|
||||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||||
@@ -1504,4 +1737,153 @@ mod tests {
|
||||
};
|
||||
assert_eq!(none.wire_mask(), None);
|
||||
}
|
||||
|
||||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||||
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||||
/// `set_pipelined`'s LN3 escalation, `applied_bitrate_bps`'s ABR truth), and every Phase 7
|
||||
/// consolidation that adds a trait method re-arms it. Source-text parse, not reflection: both
|
||||
/// blocks are top-level rustfmt items, so each ends at the first column-0 `}` and every method
|
||||
/// name sits on a line starting `fn ` (a wrapped signature still carries the name there).
|
||||
#[test]
|
||||
fn tracked_encoder_forwards_every_trait_method() {
|
||||
fn item_block<'a>(src: &'a str, marker: &str) -> &'a str {
|
||||
let start = src
|
||||
.find(marker)
|
||||
.unwrap_or_else(|| panic!("marker {marker:?} not found — update this guard"));
|
||||
let body = &src[start..];
|
||||
let end = body
|
||||
.find("\n}")
|
||||
.unwrap_or_else(|| panic!("no column-0 close brace after {marker:?}"));
|
||||
&body[..end]
|
||||
}
|
||||
fn fn_names(block: &str) -> std::collections::BTreeSet<&str> {
|
||||
block
|
||||
.lines()
|
||||
.map(str::trim_start)
|
||||
.filter(|l| !l.starts_with("//"))
|
||||
.filter_map(|l| l.strip_prefix("fn "))
|
||||
.map(|rest| {
|
||||
rest.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.next()
|
||||
.expect("split yields at least one item")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
// `find` takes the FIRST occurrence: the real impl precedes this test's own copy of the
|
||||
// marker string in the included source.
|
||||
let trait_fns = fn_names(item_block(
|
||||
include_str!("enc/codec.rs"),
|
||||
"pub trait Encoder: Send {",
|
||||
));
|
||||
let impl_fns = fn_names(item_block(
|
||||
include_str!("lib.rs"),
|
||||
"impl Encoder for TrackedEncoder {",
|
||||
));
|
||||
assert!(
|
||||
trait_fns.len() >= 12,
|
||||
"only {} trait methods parsed — the extraction markers have rotted, fix the parse \
|
||||
before trusting this guard",
|
||||
trait_fns.len()
|
||||
);
|
||||
let missing: Vec<_> = trait_fns.difference(&impl_fns).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"Encoder methods NOT forwarded by TrackedEncoder: {missing:?} — the host loop only \
|
||||
ever holds the wrapped box, so an unforwarded default silently disables the feature \
|
||||
for every session. Forward each one in `impl Encoder for TrackedEncoder`."
|
||||
);
|
||||
// The reverse direction (impl fn absent from the trait) is a compile error, so equality
|
||||
// here is pure belt-and-braces against a parse regression.
|
||||
assert_eq!(trait_fns, impl_fns);
|
||||
}
|
||||
|
||||
/// WP7.6: the resolver's full alias table, pinned. The panicking closure is the laziness
|
||||
/// contract — an explicit pref must resolve WITHOUT running the auto probe (`/serverinfo`
|
||||
/// polls through the mirrors; `gamestream/serverinfo.rs` litigated exactly that cost).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_backend_resolver_table() {
|
||||
use LinuxBackend::*;
|
||||
let no_probe = || -> bool { panic!("explicit prefs must not run the auto probe") };
|
||||
for pref in ["nvenc", "nvidia", "cuda"] {
|
||||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Nvenc));
|
||||
}
|
||||
for pref in ["vaapi", "amd", "intel"] {
|
||||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(AmdIntel));
|
||||
}
|
||||
for pref in ["vulkan", "vulkan-video"] {
|
||||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Vulkan));
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_linux_backend("pyrowave", no_probe, false),
|
||||
Some(Pyrowave)
|
||||
);
|
||||
for pref in ["software", "sw", "openh264"] {
|
||||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Software));
|
||||
}
|
||||
// auto (and the default empty pref): the probe decides…
|
||||
assert_eq!(resolve_linux_backend("", || true, false), Some(AmdIntel));
|
||||
assert_eq!(resolve_linux_backend("auto", || false, false), Some(Nvenc));
|
||||
// …except a CUDA frame, which only NVENC can consume — and that short-circuits the
|
||||
// probe too (`||` order).
|
||||
assert_eq!(resolve_linux_backend("auto", no_probe, true), Some(Nvenc));
|
||||
// Unknown prefs name no backend: the dispatch bails; the mirrors' wrapper maps this to
|
||||
// auto (the historical `_`-arm behavior, recorded in the design doc).
|
||||
assert_eq!(resolve_linux_backend("banana", no_probe, false), None);
|
||||
// An explicit pref is never overridden by `cuda` (a CUDA payload cannot appear in a
|
||||
// session whose pref forced a non-NVENC backend; the table must not mask such a bug).
|
||||
assert_eq!(
|
||||
resolve_linux_backend("vaapi", no_probe, true),
|
||||
Some(AmdIntel)
|
||||
);
|
||||
}
|
||||
|
||||
/// WP7.6: the REAL dispatch through the resolver, GPU-free via the software arm (openh264 is
|
||||
/// vendored + CPU-only; its own tests already run on every leg). This is the only in-CI
|
||||
/// execution of the Linux dispatch — before this test it had zero test call sites. The pref
|
||||
/// is INJECTED through `open_video_backend_linux` — the first draft mutated `PUNKTFUNK_ENCODER`
|
||||
/// via `set_var`, the binary's first non-ignored env mutation, racing `getenv` in the sw/codec/
|
||||
/// nvenc_core tests (diff-critic catch); the seam removes the race and the config-latch
|
||||
/// coupling outright.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn open_video_backend_dispatches_software() {
|
||||
let (enc, label) = open_video_backend_linux(
|
||||
"software",
|
||||
Codec::H264,
|
||||
PixelFormat::Bgrx,
|
||||
64,
|
||||
64,
|
||||
30,
|
||||
1_000_000,
|
||||
false,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("software arm must open GPU-free");
|
||||
assert_eq!(label, "software");
|
||||
drop(enc);
|
||||
// The software arm is codec-gated — a non-H.264 session must take the bail, not fall
|
||||
// through to another backend.
|
||||
let err = match open_video_backend_linux(
|
||||
"software",
|
||||
Codec::H265,
|
||||
PixelFormat::Bgrx,
|
||||
64,
|
||||
64,
|
||||
30,
|
||||
1_000_000,
|
||||
false,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
) {
|
||||
// `expect_err` needs `Ok: Debug` and `Box<dyn Encoder>` isn't — match instead.
|
||||
Ok(_) => panic!("software emits H.264 only; an H.265 session must be refused"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(err.to_string().contains("H.264"), "{err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ xkbcommon = "0.8"
|
||||
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The streamed-output desktop rect (CCD source rect) absolute input maps into — the same
|
||||
# resolver the cursor-readback poller uses, so inject and readback always agree.
|
||||
pf-win-display = { path = "../pf-win-display" }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
|
||||
@@ -26,6 +26,11 @@ use punktfunk_core::quic::{
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, GetThreadDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS,
|
||||
DESKTOP_CONTROL_FLAGS, HDESK,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||
use windows::Win32::UI::Controls::{
|
||||
CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE,
|
||||
POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0,
|
||||
@@ -36,9 +41,8 @@ use windows::Win32::UI::Input::Pointer::{
|
||||
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE,
|
||||
PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH,
|
||||
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE,
|
||||
PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, PEN_MASK_PRESSURE,
|
||||
PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, TOUCH_FLAG_NONE,
|
||||
TOUCH_MASK_NONE,
|
||||
};
|
||||
|
||||
@@ -46,25 +50,112 @@ use windows::Win32::UI::WindowsAndMessaging::{
|
||||
/// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it.
|
||||
const REFRESH_MS: u64 = 40;
|
||||
|
||||
/// `GENERIC_ALL` for the desktop open — the `windows` crate models desktop rights as their own
|
||||
/// flag type and doesn't re-export the generic ones (same constant `sendinput.rs` uses).
|
||||
const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000;
|
||||
|
||||
/// This thread's binding to the input desktop, restored on drop.
|
||||
///
|
||||
/// `SendInput` (`sendinput.rs`) solves the same problem by binding its DEDICATED injector thread
|
||||
/// once and keeping it. That can't be borrowed here: `inject_pen`/`inject_touch_frame` are driven
|
||||
/// from TWO threads — the caller's `apply_batch` and the `pf-pen-refresh`/`pf-touch-refresh`
|
||||
/// staleness threads — and the batch caller is a shared task thread, which must not be left parked
|
||||
/// on a `Winlogon` desktop that disappears when the prompt is dismissed. So the binding is scoped
|
||||
/// to the retry: `GetThreadDesktop` hands back a BORROWED handle (never closed), and only the
|
||||
/// `OpenInputDesktop` handle is closed, after the thread has moved back off it.
|
||||
struct InputDesktopBinding {
|
||||
previous: HDESK,
|
||||
input: HDESK,
|
||||
}
|
||||
|
||||
impl InputDesktopBinding {
|
||||
fn bind() -> Option<Self> {
|
||||
// SAFETY: FFI calls taking by-value args only. `OpenInputDesktop` yields an owned `HDESK`
|
||||
// solely on `Ok`; it is either installed by `SetThreadDesktop` (then owned by this guard,
|
||||
// closed exactly once in `Drop`) or closed here on failure — closed once on every path,
|
||||
// never used after. `SetThreadDesktop` rebinds only the calling thread, which owns no
|
||||
// windows or hooks, so it cannot fail on that account.
|
||||
unsafe {
|
||||
let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?;
|
||||
let input = OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL),
|
||||
)
|
||||
.ok()?;
|
||||
if SetThreadDesktop(input).is_err() {
|
||||
let _ = CloseDesktop(input);
|
||||
return None;
|
||||
}
|
||||
Some(Self { previous, input })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputDesktopBinding {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `previous` is the borrowed desktop this thread started on, `input` the handle
|
||||
// this guard uniquely owns. The thread moves back FIRST, so the handle is not the thread's
|
||||
// desktop when closed — closed exactly once, never used after.
|
||||
unsafe {
|
||||
let _ = SetThreadDesktop(self.previous);
|
||||
let _ = CloseDesktop(self.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject one synthetic-pointer frame, following the input desktop if Windows refuses it.
|
||||
///
|
||||
/// While a UAC consent prompt (or the lock / logon screen) owns input, the secure desktop does, and
|
||||
/// injection from the host's own `WinSta0\Default` thread comes back `ERROR_ACCESS_DENIED` — which
|
||||
/// is why pen and touch went dead on a prompt a user could SEE in the stream (capture already
|
||||
/// renders it, and `SendInput` mouse/keyboard already followed the switch, so only these two were
|
||||
/// left behind). Field-reported 2026-07-23; the exact rc reproduced in a probe the same night.
|
||||
///
|
||||
/// Measured then, with a real consent prompt up and a SYSTEM host in the console session:
|
||||
///
|
||||
/// ```text
|
||||
/// device created on Default, thread on Default -> 0x80070005 (the field failure)
|
||||
/// device created on Default, thread on INPUT desktop -> OK
|
||||
/// device created on INPUT, thread on INPUT desktop -> OK
|
||||
/// ```
|
||||
///
|
||||
/// The middle row is the load-bearing one: the synthetic pointer device is NOT desktop-affine, so
|
||||
/// rebinding the THREAD is sufficient and the device never has to be destroyed and recreated
|
||||
/// across a desktop switch (which would drop in-flight contacts and the pen's in-range state).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be a live synthetic-pointer device and `frame` a live slice for the call.
|
||||
unsafe fn inject_following_desktop(
|
||||
dev: HSYNTHETICPOINTERDEVICE,
|
||||
frame: &[POINTER_TYPE_INFO],
|
||||
) -> windows::core::Result<()> {
|
||||
// SAFETY: per this fn's contract — `dev` is live and `frame` outlives the call, which only
|
||||
// reads it. Best-effort, exactly as the direct call was.
|
||||
match InjectSyntheticPointerInput(dev, frame) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first) => {
|
||||
// Only a desktop switch is worth a rebind; anything else would just fail identically.
|
||||
let Some(_binding) = InputDesktopBinding::bind() else {
|
||||
return Err(first);
|
||||
};
|
||||
// SAFETY: same live `dev`/`frame`, re-issued with this thread on the input desktop.
|
||||
InjectSyntheticPointerInput(dev, frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
||||
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
||||
|
||||
/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels — the same surface the
|
||||
/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically.
|
||||
/// Map a normalized [0,1] coordinate pair onto desktop pixels over the STREAMED output's rect
|
||||
/// ([`crate::stream_target`]) — the surface the SendInput absolute mouse targets too, so pen,
|
||||
/// touch, and pointer all land identically. The wire normalizes over the streamed display's
|
||||
/// frame, not the desktop: mapping over the whole virtual desktop is only right when they
|
||||
/// coincide (Exclusive topology — the fallback when no stream target is live).
|
||||
fn to_screen(x: f32, y: f32) -> POINT {
|
||||
// SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in.
|
||||
let (vx, vy, vw, vh) = unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
|
||||
)
|
||||
};
|
||||
POINT {
|
||||
x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32,
|
||||
y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32,
|
||||
}
|
||||
let (px, py) = crate::stream_target::map_normalized(x as f64, y as f64);
|
||||
POINT { x: px, y: py }
|
||||
}
|
||||
|
||||
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
|
||||
@@ -340,10 +431,22 @@ fn inject_pen(sh: &mut PenShared, edge: POINTER_FLAGS, is_new: bool) {
|
||||
// SAFETY: `sh.dev.0` is the live device this wrapper owns; the one-element array is a live
|
||||
// stack value the call only reads. Best-effort like every injector write — a transient
|
||||
// failure (desktop switch) is healed by the next refresh tick re-asserting state.
|
||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &[info]) } {
|
||||
if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &[info]) } {
|
||||
if !sh.fail_warned {
|
||||
sh.fail_warned = true;
|
||||
tracing::warn!(error = %e, "pen inject failed");
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
flags = format!("{:#x}", flags.0),
|
||||
pen_flags = format!("{pen_flags:#x}"),
|
||||
pen_mask = format!("{pen_mask:#x}"),
|
||||
pressure,
|
||||
rotation,
|
||||
tilt_x,
|
||||
tilt_y,
|
||||
x = pt.x,
|
||||
y = pt.y,
|
||||
"pen inject failed"
|
||||
);
|
||||
} else {
|
||||
tracing::trace!(error = %e, "pen inject failed (transient)");
|
||||
}
|
||||
@@ -543,7 +646,7 @@ fn inject_touch_frame(sh: &mut TouchShared, edge: Option<(u32, POINTER_FLAGS)>)
|
||||
}
|
||||
// SAFETY: `sh.dev.0` is the live owned device; `frame` is a live Vec the call only reads.
|
||||
// Best-effort — a transient failure heals on the next event/refresh re-assertion.
|
||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &frame) } {
|
||||
if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &frame) } {
|
||||
if !sh.fail_warned {
|
||||
sh.fail_warned = true;
|
||||
tracing::warn!(error = %e, contacts = frame.len(), "touch inject failed");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
|
||||
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
|
||||
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
||||
//! [`super::wlr`]: absolute mouse mapped over the streamed output's rect
|
||||
//! ([`crate::stream_target`]), relative mouse for games, scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
||||
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
|
||||
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
|
||||
//! desktop switched) — no per-event reattach overhead.
|
||||
@@ -32,14 +32,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
|
||||
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
|
||||
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
|
||||
|
||||
use super::InputInjector;
|
||||
|
||||
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
|
||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||
const XBUTTON1: u32 = 0x0001;
|
||||
const XBUTTON2: u32 = 0x0002;
|
||||
@@ -165,13 +161,16 @@ impl InputInjector for SendInputInjector {
|
||||
if w == 0 || h == 0 {
|
||||
return Ok(()); // contract: drop zero extent
|
||||
}
|
||||
let (_vx, _vy, vw, vh) = virtual_desktop_rect();
|
||||
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535.
|
||||
// Client (0..w,0..h) → the STREAMED output's desktop rect
|
||||
// ([`crate::stream_target`]; the whole virtual desktop only as fallback) →
|
||||
// 0..65535 over the virtual desktop for MOUSEEVENTF_VIRTUALDESK. Mapping over
|
||||
// the desktop alone is the Extend-topology offset bug the pen plane exposed
|
||||
// (design/pen-tablet-input.md): correct only when the streamed display IS the
|
||||
// whole desktop.
|
||||
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
|
||||
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
|
||||
let ax = (cx * ABS_MAX).round() as i32;
|
||||
let ay = (cy * ABS_MAX).round() as i32;
|
||||
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
|
||||
let px = crate::stream_target::map_normalized(cx, cy);
|
||||
let (ax, ay) = crate::stream_target::desktop_px_to_virtualdesk(px);
|
||||
let mi = MOUSEINPUT {
|
||||
dx: ax,
|
||||
dy: ay,
|
||||
@@ -378,19 +377,6 @@ fn key(ki: KEYBDINPUT) -> INPUT {
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
|
||||
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
|
||||
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The streamed display every absolute coordinate maps into (design/pen-tablet-input.md field
|
||||
//! fix). Pen, touch, and absolute-mouse positions arrive normalized to the STREAMED output's
|
||||
//! frame, but the injectors historically mapped them over the whole virtual desktop — correct
|
||||
//! only when the virtual display is the sole active display (Exclusive topology, normalized to
|
||||
//! origin). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive
|
||||
//! isolate degraded to the keep-physicals fallback — the streamed output sits at a non-zero
|
||||
//! origin, so every sample landed shifted and mis-scaled (the pen exposed it first: a stylus is
|
||||
//! strictly absolute, with no closed-loop correction onto the target like a cursor).
|
||||
//!
|
||||
//! The host publishes the streamed output's CCD target id at capture bring-up
|
||||
//! ([`set_stream_target`]); the mapping sites resolve its CURRENT desktop rect through
|
||||
//! [`pf_win_display::win_display::source_desktop_rect`] — the same resolver the cursor-readback
|
||||
//! poller maps frames with, so the two directions always agree — TTL-cached because a
|
||||
//! group-layout re-arrange moves a live output's origin mid-session. With no target set, or none
|
||||
//! resolved yet, mapping falls back to the whole virtual desktop: the historical behavior, still
|
||||
//! correct for Exclusive topology and the client-less devtest paths.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
/// `(x, y, w, h)` in desktop coordinates, physical pixels (`source_desktop_rect` order).
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// How long a resolved rect stays fresh: long enough that the CCD query cost vanishes at input
|
||||
/// rates (pen samples + the 40 ms refresh threads), short enough that a mid-session layout move
|
||||
/// (a parallel session joining the auto-row) is picked up within a blink.
|
||||
const RECT_TTL: Duration = Duration::from_millis(250);
|
||||
|
||||
struct State {
|
||||
target_id: Option<u32>,
|
||||
rect: Option<Rect>,
|
||||
queried: Option<Instant>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
target_id: None,
|
||||
rect: None,
|
||||
queried: None,
|
||||
});
|
||||
|
||||
/// Publish the streamed output (its CCD target id) that absolute input maps into. The host calls
|
||||
/// this at capture bring-up; it is never cleared at teardown — a deactivated target simply stops
|
||||
/// resolving (the last-known rect is kept, and nothing injects between sessions), and the next
|
||||
/// session's bring-up re-targets. One slot per process: with parallel sessions the LAST bring-up
|
||||
/// wins for every session's absolute input — per-session routing needs source-tagged input
|
||||
/// events (parallel-displays plan) and the single slot is never worse than the historical
|
||||
/// whole-desktop mapping.
|
||||
pub fn set_stream_target(target_id: Option<u32>) {
|
||||
let mut st = STATE.lock().unwrap();
|
||||
if st.target_id != target_id {
|
||||
tracing::info!(?target_id, "absolute-input stream target set");
|
||||
st.target_id = target_id;
|
||||
st.rect = None;
|
||||
st.queried = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed output's current desktop rect, TTL-cached. `None` = no target set / never
|
||||
/// resolved (callers fall back to the whole virtual desktop).
|
||||
fn stream_rect() -> Option<Rect> {
|
||||
let mut st = STATE.lock().unwrap();
|
||||
let target_id = st.target_id?;
|
||||
let fresh = st.queried.is_some_and(|at| at.elapsed() < RECT_TTL);
|
||||
if !fresh {
|
||||
st.queried = Some(Instant::now());
|
||||
// SAFETY: read-only QueryDisplayConfig over owned locals (`source_desktop_rect`'s
|
||||
// contract — the same call the cursor-readback poller makes at spawn).
|
||||
match unsafe { pf_win_display::win_display::source_desktop_rect(target_id) } {
|
||||
Some(r) => {
|
||||
if st.rect != Some(r) {
|
||||
tracing::info!(target_id, rect = ?r, "stream-target desktop rect resolved");
|
||||
}
|
||||
st.rect = Some(r);
|
||||
}
|
||||
// Not an active path right now (teardown, or a topology commit in flight): keep the
|
||||
// last-known rect — snapping mid-stroke to the whole-desktop mapping would visibly
|
||||
// jump, and after teardown nothing injects until the next session re-targets.
|
||||
None => {
|
||||
if st.rect.is_some() {
|
||||
tracing::debug!(
|
||||
target_id,
|
||||
"stream target not an active path — keeping last rect"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.rect
|
||||
}
|
||||
|
||||
/// Desktop-space pixel for a normalized `[0,1]²` coordinate over the streamed output's rect,
|
||||
/// falling back to the whole virtual desktop when no stream target is live.
|
||||
pub(crate) fn map_normalized(nx: f64, ny: f64) -> (i32, i32) {
|
||||
map_into(stream_rect().unwrap_or_else(virtual_desktop_rect), nx, ny)
|
||||
}
|
||||
|
||||
/// Pure mapping: `[0,1]²` over `(x, y, w, h)`, inclusive edges (1.0 lands on the last pixel).
|
||||
fn map_into((x, y, w, h): Rect, nx: f64, ny: f64) -> (i32, i32) {
|
||||
(
|
||||
x + (nx.clamp(0.0, 1.0) * (w - 1).max(0) as f64).round() as i32,
|
||||
y + (ny.clamp(0.0, 1.0) * (h - 1).max(0) as f64).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// The virtual-desktop bounds `(x, y, w, h)` — the mapping fallback, and the surface
|
||||
/// `MOUSEEVENTF_VIRTUALDESK` absolute coordinates normalize over.
|
||||
pub(crate) fn virtual_desktop_rect() -> Rect {
|
||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A desktop-space pixel as the `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` 0..65535
|
||||
/// coordinate pair `SendInput` wants.
|
||||
pub(crate) fn desktop_px_to_virtualdesk(px: (i32, i32)) -> (i32, i32) {
|
||||
px_to_abs(virtual_desktop_rect(), px)
|
||||
}
|
||||
|
||||
/// SendInput absolute coordinates span 0..65535 over the chosen surface.
|
||||
const ABS_MAX: f64 = 65535.0;
|
||||
|
||||
/// Pure normalization: a desktop pixel inside `(x, y, w, h)` → 0..65535 over that surface.
|
||||
fn px_to_abs((vx, vy, vw, vh): Rect, (px, py): (i32, i32)) -> (i32, i32) {
|
||||
(
|
||||
((px - vx) as f64 * ABS_MAX / (vw - 1).max(1) as f64).round() as i32,
|
||||
((py - vy) as f64 * ABS_MAX / (vh - 1).max(1) as f64).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Extend-topology field bug: physical 1920x1080 at (0,0), streamed virtual 2560x1440
|
||||
/// beside it at (1920,0) — samples must land inside the VIRTUAL output, not at the desktop
|
||||
/// origin.
|
||||
#[test]
|
||||
fn maps_over_the_streamed_rect_not_the_desktop() {
|
||||
let r = (1920, 0, 2560, 1440);
|
||||
assert_eq!(map_into(r, 0.0, 0.0), (1920, 0));
|
||||
assert_eq!(map_into(r, 1.0, 1.0), (1920 + 2559, 1439));
|
||||
assert_eq!(map_into(r, 0.5, 0.5), (1920 + 1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_out_of_range_and_handles_negative_origins() {
|
||||
// An output placed LEFT of / ABOVE the primary has a negative desktop origin.
|
||||
let r = (-2560, -100, 2560, 1440);
|
||||
assert_eq!(map_into(r, 0.0, 0.0), (-2560, -100));
|
||||
assert_eq!(map_into(r, 2.0, -1.0), (-2560 + 2559, -100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_rect_pins_to_its_origin() {
|
||||
assert_eq!(map_into((10, 20, 0, 0), 0.7, 0.7), (10, 20));
|
||||
}
|
||||
|
||||
/// The VIRTUALDESK round trip: win32k maps an absolute coordinate back to a pixel roughly as
|
||||
/// `px = ax * vw / 65536` (floor) — edge pixels and the streamed output's origin must survive.
|
||||
#[test]
|
||||
fn virtualdesk_normalization_round_trips() {
|
||||
let v = (0, 0, 4480, 1080);
|
||||
assert_eq!(px_to_abs(v, (0, 0)), (0, 0));
|
||||
assert_eq!(px_to_abs(v, (4479, 1079)), (65535, 65535));
|
||||
let (ax, _) = px_to_abs(v, (1920, 0));
|
||||
assert_eq!((ax as i64 * 4480 / 65536) as i32, 1920);
|
||||
// Negative-origin desktops (a monitor left of the primary) still normalize from 0.
|
||||
let v = (-2560, 0, 4480, 1440);
|
||||
assert_eq!(px_to_abs(v, (-2560, 0)), (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -417,6 +417,16 @@ pub mod pen;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/pointer_windows.rs"]
|
||||
pub mod pen;
|
||||
/// Windows: the streamed output's desktop rect that every absolute coordinate (pen, touch,
|
||||
/// absolute mouse) maps into — published by the host at capture bring-up, resolved through the
|
||||
/// CCD source rect (the cursor-readback poller's resolver, so both directions agree). Mapping
|
||||
/// over the whole virtual desktop instead is the Extend-topology offset bug the pen exposed
|
||||
/// (design/pen-tablet-input.md).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/stream_target.rs"]
|
||||
pub mod stream_target;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use stream_target::set_stream_target;
|
||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -360,6 +360,28 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
||||
// the AppUserModelID adoption above).
|
||||
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
||||
// `PUNKTFUNK_DRM_CARD=<n>` → SDL's KMSDRM device index, for a compositor-less (kiosk/embedded)
|
||||
// run. SDL enumerates /dev/dri/card* and takes the first one it can open, which on a
|
||||
// multi-GPU box is regularly the WRONG one: measured on a two-card machine it chose the card
|
||||
// a live compositor already held DRM master on and failed at swapchain creation, while the
|
||||
// idle card with the connected display sat unused. There is no reliable way to pick from
|
||||
// inside the process (detecting "already mastered" needs the ioctl that taking master IS), so
|
||||
// this stays an explicit operator choice rather than fragile auto-detection.
|
||||
// Ignored unless the kmsdrm backend is actually in use.
|
||||
if let Ok(card) = std::env::var("PUNKTFUNK_DRM_CARD") {
|
||||
if card.chars().all(|c| c.is_ascii_digit()) && !card.is_empty() {
|
||||
tracing::info!(
|
||||
card,
|
||||
"PUNKTFUNK_DRM_CARD: pinning SDL's KMSDRM device index"
|
||||
);
|
||||
sdl3::hint::set("SDL_KMSDRM_DEVICE_INDEX", &card);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
card,
|
||||
"PUNKTFUNK_DRM_CARD must be a card NUMBER (e.g. 0) — ignoring"
|
||||
);
|
||||
}
|
||||
}
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
|
||||
@@ -3,9 +3,34 @@
|
||||
use super::setup::pick_formats;
|
||||
use super::{OverlayPipe, Presenter};
|
||||
use crate::csc::CscPass;
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
/// Extra guidance appended to a swapchain-creation failure when SDL is on the KMSDRM backend —
|
||||
/// i.e. a compositor-less kiosk/embedded run, where the bare Vulkan error is close to useless.
|
||||
///
|
||||
/// Measured on an NVIDIA proprietary + KMSDRM box: SDL opens the card, Vulkan enumerates the GPU
|
||||
/// *and* the display (`VK_KHR_display` reports the connected HDMI connector), then
|
||||
/// `vkCreateSwapchainKHR` returns ERROR_INITIALIZATION_FAILED — as root, with `nvidia_drm.modeset`
|
||||
/// on, on a card no compositor was using. So it is neither permissions nor DRM master: NVIDIA's
|
||||
/// direct-to-display path wants the display leased (`vkAcquireDrmDisplayEXT`) and SDL's KMSDRM
|
||||
/// surface path does not do that for it. Empty on every other backend, where the message would be
|
||||
/// noise.
|
||||
fn kmsdrm_swapchain_hint() -> String {
|
||||
let kmsdrm = std::env::var("SDL_VIDEODRIVER").is_ok_and(|v| v.eq_ignore_ascii_case("kmsdrm"));
|
||||
if !kmsdrm {
|
||||
return String::new();
|
||||
}
|
||||
let card = std::env::var("PUNKTFUNK_DRM_CARD").unwrap_or_else(|_| "unset".into());
|
||||
format!(
|
||||
" — under SDL_VIDEODRIVER=kmsdrm (PUNKTFUNK_DRM_CARD={card}). Check, in order: the card \
|
||||
has a CONNECTED connector (`cat /sys/class/drm/card*-*/status`); nothing else holds DRM \
|
||||
master on it (a running compositor does — pin another card with PUNKTFUNK_DRM_CARD=<n>); \
|
||||
and the driver is Mesa. NVIDIA's proprietary direct-display path is known to fail here \
|
||||
even as root with a display Vulkan can enumerate."
|
||||
)
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
/// (Re)build the swapchain for the window's current pixel size. Also the resize path.
|
||||
pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> {
|
||||
@@ -66,8 +91,8 @@ impl Presenter {
|
||||
.present_mode(self.present_mode)
|
||||
.clipped(true)
|
||||
.old_swapchain(old);
|
||||
let swapchain =
|
||||
unsafe { self.swap_d.create_swapchain(&info, None) }.context("vkCreateSwapchainKHR")?;
|
||||
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
|
||||
.map_err(|e| anyhow!("vkCreateSwapchainKHR: {e}{}", kmsdrm_swapchain_hint()))?;
|
||||
// The old swapchain and everything tied to its images dies NOW: the fence
|
||||
// quiesce covered our own command buffers, the queue drain above covered the
|
||||
// presentation engine's semaphore waits — nothing can still reference them.
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::dmabuf;
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
use ash::vk::Handle as _;
|
||||
use std::ffi::CString;
|
||||
use std::ffi::{c_char, CString};
|
||||
|
||||
impl Presenter {
|
||||
/// Bring up instance → surface → device → swapchain over an SDL window.
|
||||
@@ -40,7 +40,10 @@ impl Presenter {
|
||||
.iter()
|
||||
.map(|e| CString::new(e.as_str()).unwrap())
|
||||
.collect();
|
||||
let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect();
|
||||
// `c_char`, not `i8`: plain `char` is SIGNED on x86_64 but UNSIGNED on aarch64, so a
|
||||
// hardcoded `*const i8` compiles on the desktop targets and fails to match ash's
|
||||
// `&[*const c_char]` on ARM.
|
||||
let ext_ptrs: Vec<*const c_char> = ext_cstrings.iter().map(|e| e.as_ptr()).collect();
|
||||
let instance = unsafe {
|
||||
entry.create_instance(
|
||||
&vk::InstanceCreateInfo::default()
|
||||
|
||||
@@ -45,6 +45,9 @@ futures-util = "0.3"
|
||||
wayland-client = "0.31"
|
||||
wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
|
||||
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
|
||||
bitflags = "2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="kde_output_device_v2">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
|
||||
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
|
||||
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
|
||||
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
|
||||
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
|
||||
|
||||
SPDX-License-Identifier: MIT-CMU
|
||||
]]></copyright>
|
||||
|
||||
<interface name="kde_output_device_registry_v2" version="24">
|
||||
<description summary="output devices">
|
||||
This interface can be used to list output devices.
|
||||
|
||||
If this global is bound with a version less than 21, the unsupported_version
|
||||
protocol error will be posted.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<description summary="kde_output_device_registry_v2 error values">
|
||||
These errors can be emitted in response to some requests.
|
||||
</description>
|
||||
<entry name="unsupported_version" value="0"
|
||||
summary="the registry was bound with an unsupported version"/>
|
||||
</enum>
|
||||
|
||||
<event name="finished" type="destructor" since="21">
|
||||
<description summary="no new output announcements">
|
||||
This event is sent in response to the stop request. The compositor will
|
||||
immediately destroy the object after sending this event.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<request name="stop" since="21">
|
||||
<description summary="stop receiving updates">
|
||||
This request indicates that the client no longer wants to receive new
|
||||
output announcements. The compositor will send the
|
||||
kde_output_device_registry_v2.finished event in response to this request.
|
||||
The compositor may still send new output announcements after calling this
|
||||
request until the kde_output_device_registry_v2.finished event is sent.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="output" since="21">
|
||||
<description summary="new available output">
|
||||
This event is sent when a new output is connected or after binding this
|
||||
global to list all available outputs.
|
||||
</description>
|
||||
<arg name="output" type="new_id" interface="kde_output_device_v2"/>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
<interface name="kde_output_device_v2" version="24">
|
||||
<description summary="output configuration representation">
|
||||
An output device describes a display device available to the compositor.
|
||||
output_device is similar to wl_output, but focuses on output
|
||||
configuration management.
|
||||
|
||||
A client can query all global output_device objects to enlist all
|
||||
available display devices, even those that may currently not be
|
||||
represented by the compositor as a wl_output.
|
||||
|
||||
The client sends configuration changes to the server through the
|
||||
outputconfiguration interface, and the server applies the configuration
|
||||
changes to the hardware and signals changes to the output devices
|
||||
accordingly.
|
||||
|
||||
This object is published as global during start up for every available
|
||||
display devices, or when one later becomes available, for example by
|
||||
being hotplugged via a physical connector.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment
|
||||
implementation detail. Regular clients must not use this protocol.
|
||||
Backward incompatible changes may be added without bumping the major
|
||||
version of the extension.
|
||||
</description>
|
||||
|
||||
<enum name="subpixel">
|
||||
<description summary="subpixel geometry information">
|
||||
This enumeration describes how the physical pixels on an output are
|
||||
laid out.
|
||||
</description>
|
||||
<entry name="unknown" value="0"/>
|
||||
<entry name="none" value="1"/>
|
||||
<entry name="horizontal_rgb" value="2"/>
|
||||
<entry name="horizontal_bgr" value="3"/>
|
||||
<entry name="vertical_rgb" value="4"/>
|
||||
<entry name="vertical_bgr" value="5"/>
|
||||
</enum>
|
||||
|
||||
<enum name="transform">
|
||||
<description summary="transform from framebuffer to output">
|
||||
This describes the transform, that a compositor will apply to a
|
||||
surface to compensate for the rotation or mirroring of an
|
||||
output device.
|
||||
|
||||
The flipped values correspond to an initial flip around a
|
||||
vertical axis followed by rotation.
|
||||
|
||||
The purpose is mainly to allow clients to render accordingly and
|
||||
tell the compositor, so that for fullscreen surfaces, the
|
||||
compositor is still able to scan out directly client surfaces.
|
||||
</description>
|
||||
|
||||
<entry name="normal" value="0"/>
|
||||
<entry name="90" value="1"/>
|
||||
<entry name="180" value="2"/>
|
||||
<entry name="270" value="3"/>
|
||||
<entry name="flipped" value="4"/>
|
||||
<entry name="flipped_90" value="5"/>
|
||||
<entry name="flipped_180" value="6"/>
|
||||
<entry name="flipped_270" value="7"/>
|
||||
</enum>
|
||||
|
||||
<event name="geometry">
|
||||
<description summary="geometric properties of the output">
|
||||
The geometry event describes geometric properties of the output.
|
||||
The event is sent when binding to the output object and whenever
|
||||
any of the properties change.
|
||||
</description>
|
||||
<arg name="x" type="int"
|
||||
summary="x position within the global compositor space"/>
|
||||
<arg name="y" type="int"
|
||||
summary="y position within the global compositor space"/>
|
||||
<arg name="physical_width" type="int"
|
||||
summary="width in millimeters of the output"/>
|
||||
<arg name="physical_height" type="int"
|
||||
summary="height in millimeters of the output"/>
|
||||
<arg name="subpixel" type="int"
|
||||
summary="subpixel orientation of the output"/>
|
||||
<arg name="make" type="string"
|
||||
summary="textual description of the manufacturer"/>
|
||||
<arg name="model" type="string"
|
||||
summary="textual description of the model"/>
|
||||
<arg name="transform" type="int"
|
||||
summary="transform that maps framebuffer to output"/>
|
||||
</event>
|
||||
|
||||
<event name="current_mode">
|
||||
<description summary="current mode">
|
||||
This event describes the mode currently in use for this head. It is only
|
||||
sent if the output is enabled.
|
||||
</description>
|
||||
<arg name="mode" type="object" interface="kde_output_device_mode_v2"/>
|
||||
</event>
|
||||
|
||||
<event name="mode">
|
||||
<description summary="advertise available output modes and current one">
|
||||
The mode event describes an available mode for the output.
|
||||
|
||||
When the client binds to the output_device object, the server sends this
|
||||
event once for every available mode the output_device can be operated by.
|
||||
|
||||
There will always be at least one event sent out on initial binding,
|
||||
which represents the current mode.
|
||||
|
||||
Later if an output changes, its mode event is sent again for the
|
||||
eventual added modes and lastly the current mode. In other words, the
|
||||
current mode is always represented by the latest event sent with the current
|
||||
flag set.
|
||||
|
||||
The size of a mode is given in physical hardware units of the output device.
|
||||
This is not necessarily the same as the output size in the global compositor
|
||||
space. For instance, the output may be scaled, as described in
|
||||
kde_output_device_v2.scale, or transformed, as described in
|
||||
kde_output_device_v2.transform.
|
||||
</description>
|
||||
<arg name="mode" type="new_id" interface="kde_output_device_mode_v2"/>
|
||||
</event>
|
||||
|
||||
<event name="done">
|
||||
<description summary="sent all information about output">
|
||||
This event is sent after all other properties have been
|
||||
sent on binding to the output object as well as after any
|
||||
other output property change have been applied later on.
|
||||
This allows to see changes to the output properties as atomic,
|
||||
even if multiple events successively announce them.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="scale">
|
||||
<description summary="output scaling properties">
|
||||
This event contains scaling geometry information
|
||||
that is not in the geometry event. It may be sent after
|
||||
binding the output object or if the output scale changes
|
||||
later. If it is not sent, the client should assume a
|
||||
scale of 1.
|
||||
|
||||
A scale larger than 1 means that the compositor will
|
||||
automatically scale surface buffers by this amount
|
||||
when rendering. This is used for high resolution
|
||||
displays where applications rendering at the native
|
||||
resolution would be too small to be legible.
|
||||
|
||||
It is intended that scaling aware clients track the
|
||||
current output of a surface, and if it is on a scaled
|
||||
output it should use wl_surface.set_buffer_scale with
|
||||
the scale of the output. That way the compositor can
|
||||
avoid scaling the surface, and the client can supply
|
||||
a higher detail image.
|
||||
</description>
|
||||
<arg name="factor" type="fixed" summary="scaling factor of output"/>
|
||||
</event>
|
||||
|
||||
<event name="edid">
|
||||
<description summary="advertise EDID data for the output">
|
||||
The edid event encapsulates the EDID data for the outputdevice.
|
||||
|
||||
The event is sent when binding to the output object. The EDID
|
||||
data may be empty, in which case this event is sent anyway.
|
||||
If the EDID information is empty, you can fall back to the name
|
||||
et al. properties of the outputdevice.
|
||||
</description>
|
||||
<arg name="raw" type="string" summary="base64-encoded EDID string"/>
|
||||
</event>
|
||||
|
||||
<event name="enabled">
|
||||
<description summary="output is enabled or disabled">
|
||||
The enabled event notifies whether this output is currently
|
||||
enabled and used for displaying content by the server.
|
||||
The event is sent when binding to the output object and
|
||||
whenever later on an output changes its state by becoming
|
||||
enabled or disabled.
|
||||
</description>
|
||||
<arg name="enabled" type="int" summary="output enabled state"/>
|
||||
</event>
|
||||
|
||||
<event name="uuid">
|
||||
<description summary="A unique id for this outputdevice">
|
||||
The uuid can be used to identify the output. It's controlled by
|
||||
the server entirely. The server should make sure the uuid is
|
||||
persistent across restarts. An empty uuid is considered invalid.
|
||||
</description>
|
||||
<arg name="uuid" type="string" summary="output devices ID"/>
|
||||
</event>
|
||||
|
||||
<event name="serial_number">
|
||||
<description summary="Serial Number">
|
||||
Serial ID of the monitor, sent on startup before the first done event.
|
||||
</description>
|
||||
<arg name="serialNumber" type="string"
|
||||
summary="textual representation of serial number"/>
|
||||
</event>
|
||||
<event name="eisa_id">
|
||||
<description summary="EISA ID">
|
||||
EISA ID of the monitor, sent on startup before the first done event.
|
||||
</description>
|
||||
<arg name="eisaId" type="string"
|
||||
summary="textual representation of EISA identifier"/>
|
||||
</event>
|
||||
|
||||
<enum name="capability" bitfield="true">
|
||||
<description summary="describes capabilities of the outputdevice">
|
||||
Describes what capabilities this device has.
|
||||
</description>
|
||||
<entry name="overscan" value="0x1"
|
||||
summary="if this output_device can use overscan"/>
|
||||
<entry name="vrr" value="0x2"
|
||||
summary="if this outputdevice supports variable refresh rate"/>
|
||||
<entry name="rgb_range" value="0x4"
|
||||
summary="if setting the rgb range is possible"/>
|
||||
<entry name="high_dynamic_range" value="0x8" since="3"
|
||||
summary="if this outputdevice supports high dynamic range"/>
|
||||
<entry name="wide_color_gamut" value="0x10" since="3"
|
||||
summary="if this outputdevice supports a wide color gamut"/>
|
||||
<entry name="auto_rotate" value="0x20" since="4"
|
||||
summary="if this outputdevice supports autorotation"/>
|
||||
<entry name="icc_profile" value="0x40" since="5"
|
||||
summary="if this outputdevice supports icc profiles"/>
|
||||
<entry name="brightness" value="0x80" since="9"
|
||||
summary="if this outputdevice supports the brightness setting"/>
|
||||
<entry name="built_in_color" value="0x100" since="12"
|
||||
summary="if this outputdevice supports the built-in color profile"/>
|
||||
<entry name="ddc_ci" value="0x200" since="14"
|
||||
summary="if this outputdevice supports DDC/CI"/>
|
||||
<entry name="max_bits_per_color" value="0x400" since="15"
|
||||
summary="if this outputdevice supports setting max bpc"/>
|
||||
<entry name="edr" value="0x800" since="16"
|
||||
summary="if this outputdevice supports EDR"/>
|
||||
<entry name="sharpness" value="0x1000" since="17"
|
||||
summary="if this outputdevice supports the sharpness setting"/>
|
||||
<entry name="custom_modes" value="0x2000" since="18"
|
||||
summary="if this outputdevice supports custom modes"/>
|
||||
<entry name="auto_brightness" value = "0x4000" since="19"/>
|
||||
<entry name="hdr_icc_profile" value="0x8000" since="22"
|
||||
summary="if this outputdevice supports HDR ICC profiles"/>
|
||||
<entry name="abm_level" value="0x10000" since="23"
|
||||
summary="if this outputdevice supports the abm level setting"/>
|
||||
</enum>
|
||||
|
||||
<event name="capabilities">
|
||||
<description summary="capability flags">
|
||||
What capabilities this device has, sent on startup before the first
|
||||
done event.
|
||||
</description>
|
||||
<arg name="flags" type="uint" enum="capability"/>
|
||||
</event>
|
||||
|
||||
<event name="overscan">
|
||||
<description summary="overscan">
|
||||
Overscan value of the monitor in percent, sent on startup before the
|
||||
first done event.
|
||||
</description>
|
||||
<arg name="overscan" type="uint"
|
||||
summary="amount of overscan of the monitor"/>
|
||||
</event>
|
||||
|
||||
<enum name="vrr_policy">
|
||||
<description summary="describes vrr policy">
|
||||
Describes when the compositor may employ variable refresh rate
|
||||
</description>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="always" value="1"/>
|
||||
<entry name="automatic" value="2"/>
|
||||
</enum>
|
||||
|
||||
<event name="vrr_policy">
|
||||
<description summary="Variable Refresh Rate Policy">
|
||||
What policy the compositor will employ regarding its use of variable
|
||||
refresh rate.
|
||||
</description>
|
||||
<arg name="vrr_policy" type="uint" enum="vrr_policy"/>
|
||||
</event>
|
||||
|
||||
<enum name="rgb_range">
|
||||
<description summary="describes RGB range policy">
|
||||
Whether full or limited color range should be used
|
||||
</description>
|
||||
<entry name="automatic" value="0"/>
|
||||
<entry name="full" value="1"/>
|
||||
<entry name="limited" value="2"/>
|
||||
</enum>
|
||||
|
||||
<event name="rgb_range">
|
||||
<description summary="RGB range">
|
||||
What rgb range the compositor is using for this output
|
||||
</description>
|
||||
<arg name="rgb_range" type="uint" enum="rgb_range"/>
|
||||
</event>
|
||||
|
||||
<event name="name" since="2">
|
||||
<description summary="Output's name">
|
||||
Name of the output, it's useful to cross-reference to an zxdg_output_v1 and ultimately QScreen
|
||||
</description>
|
||||
<arg name="name" type="string"/>
|
||||
</event>
|
||||
|
||||
<event name="high_dynamic_range" since="3">
|
||||
<description summary="if HDR is enabled">
|
||||
Whether or not high dynamic range is enabled for this output
|
||||
</description>
|
||||
<arg name="hdr_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
|
||||
</event>
|
||||
|
||||
<event name="sdr_brightness" since="3">
|
||||
<description summary="the brightness of sdr if hdr is enabled">
|
||||
If high dynamic range is used, this value defines the brightness in nits for content
|
||||
that's in standard dynamic range format. Note that while the value is in nits, that
|
||||
doesn't necessarily translate to the same brightness on the screen.
|
||||
</description>
|
||||
<arg name="sdr_brightness" type="uint"/>
|
||||
</event>
|
||||
|
||||
<event name="wide_color_gamut" since="3">
|
||||
<description summary="if WCG is enabled">
|
||||
Whether or not the use of a wide color gamut is enabled for this output
|
||||
</description>
|
||||
<arg name="wcg_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
|
||||
</event>
|
||||
|
||||
<enum name="auto_rotate_policy">
|
||||
<description summary="describes when auto rotate should be used"/>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="in_tablet_mode" value="1"/>
|
||||
<entry name="always" value="2"/>
|
||||
</enum>
|
||||
|
||||
<event name="auto_rotate_policy" since="4">
|
||||
<description summary="describes when auto rotate is used"/>
|
||||
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
|
||||
</event>
|
||||
|
||||
<event name="icc_profile_path" since="5">
|
||||
<description summary="describes the path to the ICC profile used in SDR mode"/>
|
||||
<arg name="profile_path" type="string"/>
|
||||
</event>
|
||||
|
||||
<event name="brightness_metadata" since="6">
|
||||
<description summary="metadata about the screen's brightness limits"/>
|
||||
<arg name="max_peak_brightness" type="uint" summary="in nits"/>
|
||||
<arg name="max_frame_average_brightness" type="uint" summary="in nits"/>
|
||||
<arg name="min_brightness" type="uint" summary="in 0.0001 nits"/>
|
||||
</event>
|
||||
|
||||
<event name="brightness_overrides" since="6">
|
||||
<description summary="overrides for the screen's brightness limits"/>
|
||||
<arg name="max_peak_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
|
||||
<arg name="max_average_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
|
||||
<arg name="min_brightness" type="int" summary="-1 for no override, positive values are the brightness in 0.0001 nits"/>
|
||||
</event>
|
||||
|
||||
<event name="sdr_gamut_wideness" since="6">
|
||||
<description summary="describes which gamut is assumed for sRGB applications">
|
||||
This can be used to provide the colors users assume sRGB applications should have based on the
|
||||
default experience on many modern sRGB screens.
|
||||
</description>
|
||||
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
|
||||
</event>
|
||||
|
||||
<enum name="color_profile_source" since="7">
|
||||
<description summary="which source the compositor should use for the color profile on an output"/>
|
||||
<entry name="sRGB" value="0"/>
|
||||
<entry name="ICC" value="1"/>
|
||||
<entry name="EDID" value="2"/>
|
||||
</enum>
|
||||
|
||||
<event name="color_profile_source" since="7">
|
||||
<description summary="describes which source the compositor uses for the color profile on an output in SDR mode"/>
|
||||
<arg name="source" type="uint" enum="color_profile_source"/>
|
||||
</event>
|
||||
|
||||
<event name="brightness" since="8">
|
||||
<description summary="brightness multiplier">
|
||||
This is the brightness modifier of the output. It doesn't specify
|
||||
any absolute values, but is merely a multiplier on top of other
|
||||
brightness values, like sdr_brightness and brightness_metadata.
|
||||
0 is the minimum brightness (not completely dark) and 10000 is
|
||||
the maximum brightness.
|
||||
This is currently only supported / meaningful while HDR is active.
|
||||
</description>
|
||||
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
|
||||
</event>
|
||||
|
||||
<enum name="color_power_tradeoff">
|
||||
<description summary="tradeoff between power and accuracy">
|
||||
The compositor can do a lot of things that trade between
|
||||
performance, power and color accuracy. This setting describes
|
||||
a high level preference from the user about in which direction
|
||||
that tradeoff should be made.
|
||||
</description>
|
||||
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
|
||||
<entry name="accuracy" value="1" summary="prefer accuracy"/>
|
||||
</enum>
|
||||
|
||||
<event name="color_power_tradeoff" since="10">
|
||||
<description summary="the preferred color/power tradeoff"/>
|
||||
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
|
||||
</event>
|
||||
|
||||
<event name="dimming" since="11">
|
||||
<description summary="dimming multiplier">
|
||||
This is the dimming multiplier of the output. This is similar to
|
||||
the brightness setting, except it's meant to be a temporary setting
|
||||
only, not persistent and may be implemented differently depending
|
||||
on the display.
|
||||
0 is the minimum dimming factor (not completely dark) and 10000
|
||||
means the output is not dimmed.
|
||||
</description>
|
||||
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
|
||||
</event>
|
||||
|
||||
<event name="replication_source" since="13">
|
||||
<description summary="source output for mirroring"/>
|
||||
<arg name="source" type="string" summary="uuid of the source output"/>
|
||||
</event>
|
||||
|
||||
<event name="ddc_ci_allowed" since="14">
|
||||
<description summary="if DDC/CI should be used to control brightness etc.">
|
||||
If the ddc_ci capability is present, this determines if settings
|
||||
such as brightness, contrast or others should be set using DDC/CI.
|
||||
</description>
|
||||
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
|
||||
</event>
|
||||
|
||||
<event name="max_bits_per_color" since="15">
|
||||
<description summary="override max bpc">
|
||||
This limits the amount of bits per color that are sent to the display.
|
||||
</description>
|
||||
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
|
||||
</event>
|
||||
|
||||
<event name="max_bits_per_color_range" since="15">
|
||||
<description summary="range of max bits per color value"/>
|
||||
<arg name="min_value" type="uint" summary="the minimum supported by the driver"/>
|
||||
<arg name="max_value" type="uint" summary="the maximum supported by the driver"/>
|
||||
</event>
|
||||
|
||||
<event name="automatic_max_bits_per_color_limit" since="15">
|
||||
<description summary="if and to what value automatic max bpc is limited"/>
|
||||
<arg name="max_bpc_limit" type="uint"
|
||||
summary="which value automatic bpc gets limited to. 0 if not limited"/>
|
||||
</event>
|
||||
|
||||
<enum name="edr_policy" since="16">
|
||||
<description summary="when the compositor may make use of EDR"/>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="always" value="1"/>
|
||||
</enum>
|
||||
|
||||
<event name="edr_policy" since="16">
|
||||
<description summary="when the compositor may apply EDR">
|
||||
When EDR is enabled, the compositor may increase the backlight beyond
|
||||
the user-specified setting, in order to present HDR content on displays
|
||||
without native HDR support.
|
||||
This will usually result in better visuals, but also increases battery
|
||||
usage.
|
||||
</description>
|
||||
<arg name="policy" type="uint" enum="edr_policy"/>
|
||||
</event>
|
||||
|
||||
<event name="sharpness" since="17">
|
||||
<description summary="sharpness strength">
|
||||
This is the sharpness modifier of the output.
|
||||
0 is sharpness disabled and 10000 is the maximum sharpness
|
||||
</description>
|
||||
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
|
||||
</event>
|
||||
|
||||
<event name="priority" since="18">
|
||||
<description summary="output priority">
|
||||
Describes the position of the output in the output order list,
|
||||
with lower values being earlier in the list. There's no specific
|
||||
value the list has to start at, this value is only used in sorting
|
||||
outputs.
|
||||
|
||||
Note that the output order protocol is not sufficient for this,
|
||||
as an output may not be in the output order if it's disabled or
|
||||
mirroring another screen.
|
||||
</description>
|
||||
<arg name="priority" type="uint" summary="priority"/>
|
||||
</event>
|
||||
|
||||
<event name="auto_brightness" since="20">
|
||||
<description summary="whether or not automatic brightness is enabled"/>
|
||||
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
|
||||
</event>
|
||||
|
||||
<request name="release" type="destructor" since="21">
|
||||
<description summary="destroy the output device">
|
||||
This notifies the compositor that the client no longer wishes to use
|
||||
the kde_output_device_v2 object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="removed" since="21">
|
||||
<description summary="the output has been removed">
|
||||
This event is sent when the output device is disconnected and no new
|
||||
updates will be sent. The client should call the kde_output_device_v2.release
|
||||
request after receiving this event.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="hdr_icc_profile_path" since="22">
|
||||
<description summary="describes the path to the ICC profile used in HDR mode"/>
|
||||
<arg name="profile_path" type="string"/>
|
||||
</event>
|
||||
|
||||
<event name="hdr_color_profile_source" since="22">
|
||||
<description summary="describes which source the compositor uses for the color profile on an output in HDR mode"/>
|
||||
<arg name="source" type="uint" enum="color_profile_source"/>
|
||||
</event>
|
||||
|
||||
<event name="abm_level" since="23">
|
||||
<description summary="allowed level of adaptive backlight modulation">
|
||||
Adaptive backlight modulation is a feature that reduces the backlight
|
||||
and increases contrast of colors on the screen to improve power usage.
|
||||
</description>
|
||||
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
<interface name="kde_output_device_mode_v2" version="24">
|
||||
<description summary="output mode">
|
||||
This object describes an output mode.
|
||||
|
||||
Some heads don't support output modes, in which case modes won't be
|
||||
advertised.
|
||||
|
||||
Properties sent via this interface are applied atomically via the
|
||||
kde_output_device.done event. No guarantees are made regarding the order
|
||||
in which properties are sent.
|
||||
</description>
|
||||
|
||||
<event name="size">
|
||||
<description summary="mode size">
|
||||
This event describes the mode size. The size is given in physical
|
||||
hardware units of the output device. This is not necessarily the same as
|
||||
the output size in the global compositor space. For instance, the output
|
||||
may be scaled or transformed.
|
||||
</description>
|
||||
<arg name="width" type="int" summary="width of the mode in hardware units"/>
|
||||
<arg name="height" type="int" summary="height of the mode in hardware units"/>
|
||||
</event>
|
||||
|
||||
<event name="refresh">
|
||||
<description summary="mode refresh rate">
|
||||
This event describes the mode's fixed vertical refresh rate. It is only
|
||||
sent if the mode has a fixed refresh rate.
|
||||
</description>
|
||||
<arg name="refresh" type="int" summary="vertical refresh rate in mHz"/>
|
||||
</event>
|
||||
|
||||
<event name="preferred">
|
||||
<description summary="mode is preferred">
|
||||
This event advertises this mode as preferred.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="removed">
|
||||
<description summary="the mode has been destroyed">
|
||||
The compositor will destroy the object immediately after sending this
|
||||
event, so it will become invalid and the client should release any
|
||||
resources associated with it.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<enum name="flags">
|
||||
<description summary="mode flags"/>
|
||||
<entry name="custom" value="0x1"/>
|
||||
<entry name="reduced_blanking" value="0x2" deprecated-since="24"/>
|
||||
</enum>
|
||||
|
||||
<event name="flags" since="19">
|
||||
<description summary="mode flags">
|
||||
This event describes the mode's flags.
|
||||
</description>
|
||||
<arg name="flags" type="uint" enum="flags"/>
|
||||
</event>
|
||||
|
||||
<event name="cvt" since="24">
|
||||
<description summary="cvt timings">
|
||||
This event describes the CVT timings associated with the mode.
|
||||
|
||||
If an output mode has no CVT timings, this event will not be sent.
|
||||
</description>
|
||||
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
|
||||
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
|
||||
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
|
||||
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
|
||||
<arg name="htotal" type="uint" summary="horizontal total size"/>
|
||||
<arg name="hskew" type="uint" summary="horizontal skew"/>
|
||||
<arg name="vdisplay" type="uint" summary="vertical display size"/>
|
||||
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
|
||||
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
|
||||
<arg name="vtotal" type="uint" summary="vertical total size"/>
|
||||
<arg name="vscan" type="uint" summary="vertical scan"/>
|
||||
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
</protocol>
|
||||
@@ -0,0 +1,539 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="kde_output_management_v2">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
|
||||
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
|
||||
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
|
||||
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
|
||||
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
|
||||
SPDX-FileCopyrightText: 2023 Xaver Hugl <xaver.hugl@kde.org>
|
||||
|
||||
SPDX-License-Identifier: MIT-CMU
|
||||
]]></copyright>
|
||||
|
||||
<interface name="kde_output_management_v2" version="22">
|
||||
<description summary="configuration of server outputs through clients">
|
||||
This interface enables clients to set properties of output devices for screen
|
||||
configuration purposes via the server. To this end output devices are referenced
|
||||
by global kde_output_device_v2 objects.
|
||||
|
||||
outputmanagement (wl_global)
|
||||
--------------------------
|
||||
request:
|
||||
* create_configuration -> outputconfiguration (wl_resource)
|
||||
|
||||
outputconfiguration (wl_resource)
|
||||
--------------------------
|
||||
requests:
|
||||
* enable(outputdevice, bool)
|
||||
* mode(outputdevice, mode)
|
||||
* transformation(outputdevice, flag)
|
||||
* position(outputdevice, x, y)
|
||||
* apply
|
||||
|
||||
events:
|
||||
* applied
|
||||
* failed
|
||||
|
||||
The server registers one outputmanagement object as a global object. In order
|
||||
to configure outputs a client requests create_configuration, which provides a
|
||||
resource referencing an outputconfiguration for one-time configuration. That
|
||||
way the server knows which requests belong together and can group them by that.
|
||||
|
||||
On the outputconfiguration object the client calls for each output whether the
|
||||
output should be enabled, which mode should be set (by referencing the mode from
|
||||
the list of announced modes) and the output's global position. Once all outputs
|
||||
are configured that way, the client calls apply.
|
||||
At that point and not earlier the server should try to apply the configuration.
|
||||
If this succeeds the server emits the applied signal, otherwise the failed
|
||||
signal, such that the configuring client is noticed about the success of its
|
||||
configuration request.
|
||||
|
||||
Through this design the interface enables atomic output configuration changes if
|
||||
internally supported by the server.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment implementation
|
||||
detail. Regular clients must not use this protocol. Backward incompatible
|
||||
changes may be added without bumping the major version of the extension.
|
||||
</description>
|
||||
<request name="create_configuration">
|
||||
<description summary="provide outputconfiguration object for configuring outputs">
|
||||
Request an outputconfiguration object through which the client can configure
|
||||
output devices.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="kde_output_configuration_v2"/>
|
||||
</request>
|
||||
|
||||
<request name="create_mode_list">
|
||||
<description summary="create a list of custom modes">
|
||||
For details, see the description of kde_mode_list_v2 and
|
||||
kde_output_configuration_v2.set_custom_modes.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="kde_mode_list_v2"/>
|
||||
</request>
|
||||
|
||||
</interface>
|
||||
|
||||
<interface name="kde_output_configuration_v2" version="22">
|
||||
<description summary="configure single output devices">
|
||||
outputconfiguration is a client-specific resource that can be used to ask
|
||||
the server to apply changes to available output devices.
|
||||
|
||||
The client receives a list of output devices from the registry. When it wants
|
||||
to apply new settings, it creates a configuration object from the
|
||||
outputmanagement global, writes changes through this object's enable, scale,
|
||||
transform and mode calls. It then asks the server to apply these settings in
|
||||
an atomic fashion, for example through Linux' DRM interface.
|
||||
|
||||
The server signals back whether the new settings have applied successfully
|
||||
or failed to apply. outputdevice objects are updated after the changes have been
|
||||
applied to the hardware and before the server side sends the applied event.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<description summary="kde_output_configuration_v2 error values">
|
||||
These error can be emitted in response to kde_output_configuration_v2 requests.
|
||||
</description>
|
||||
<entry name="already_applied" value="0" summary="the config is already applied"/>
|
||||
</enum>
|
||||
|
||||
<request name="enable">
|
||||
<description summary="enable or disable an output">
|
||||
Mark the output as enabled or disabled.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice to be en- or disabled"/>
|
||||
<arg name="enable" type="int" summary="1 to enable or 0 to disable this output"/>
|
||||
</request>
|
||||
|
||||
<request name="mode">
|
||||
<description summary="switch output-device to mode">
|
||||
Sets the mode for a given output.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this mode change applies to"/>
|
||||
<arg name="mode" type="object" interface="kde_output_device_mode_v2" summary="the mode to apply"/>
|
||||
</request>
|
||||
|
||||
<request name="transform">
|
||||
<description summary="transform output-device">
|
||||
Sets the transformation for a given output.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this transformation change applies to"/>
|
||||
<arg name="transform" type="int" summary="transform enum"/>
|
||||
</request>
|
||||
|
||||
<request name="position">
|
||||
<description summary="position output in global space">
|
||||
Sets the position for this output device. (x,y) describe the top-left corner
|
||||
of the output in global space, whereby the origin (0,0) of the global space
|
||||
has to be aligned with the top-left corner of the most left and in case this
|
||||
does not define a single one the top output.
|
||||
|
||||
There may be no gaps or overlaps between outputs, i.e. the outputs are
|
||||
stacked horizontally, vertically, or both on each other.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this position applies to"/>
|
||||
<arg name="x" type="int" summary="position on the x-axis"/>
|
||||
<arg name="y" type="int" summary="position on the y-axis"/>
|
||||
</request>
|
||||
|
||||
<request name="scale">
|
||||
<description summary="set scaling factor of this output">
|
||||
Sets the scaling factor for this output device.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this scale change applies to"/>
|
||||
<arg name="scale" type="fixed" summary="scaling factor"/>
|
||||
</request>
|
||||
|
||||
<request name="apply">
|
||||
<description summary="apply configuration changes to all output devices">
|
||||
Asks the server to apply property changes requested through this outputconfiguration
|
||||
object to all outputs on the server side.
|
||||
|
||||
The output configuration can be applied only once. The already_applied protocol error
|
||||
will be posted if the apply request is called the second time.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="applied">
|
||||
<description summary="configuration changes have been applied">
|
||||
Sent after the server has successfully applied the changes.
|
||||
.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="failed">
|
||||
<description summary="configuration changes failed to apply">
|
||||
Sent if the server rejects the changes or failed to apply them.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="release the outputconfiguration object"/>
|
||||
</request>
|
||||
|
||||
<request name="overscan">
|
||||
<description summary="set overscan value">
|
||||
Set the overscan value of this output device with a value in percent.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice overscan applies to"/>
|
||||
<arg name="overscan" type="uint" summary="overscan value"/>
|
||||
</request>
|
||||
|
||||
<enum name="vrr_policy">
|
||||
<description summary="describes vrr policy">
|
||||
Describes when the compositor may employ variable refresh rate
|
||||
</description>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="always" value="1"/>
|
||||
<entry name="automatic" value="2"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_vrr_policy">
|
||||
<description summary="set the VRR policy">
|
||||
Set what policy the compositor should employ regarding its use of
|
||||
variable refresh rate.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this VRR policy applies to"/>
|
||||
<arg name="policy" type="uint" enum="vrr_policy" summary="the vrr policy to apply"/>
|
||||
</request>
|
||||
|
||||
<enum name="rgb_range">
|
||||
<description summary="describes RGB range policy">
|
||||
Whether this output should use full or limited rgb.
|
||||
</description>
|
||||
<entry name="automatic" value="0"/>
|
||||
<entry name="full" value="1"/>
|
||||
<entry name="limited" value="2"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_rgb_range">
|
||||
<description summary="RGB range">
|
||||
Whether full or limited color range should be used
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the rgb range applies to"/>
|
||||
<arg name="rgb_range" type="uint" enum="rgb_range"/>
|
||||
</request>
|
||||
|
||||
<request name="set_primary_output" since="2">
|
||||
<description summary="Select which primary output to use" />
|
||||
<arg name="output" type="object" interface="kde_output_device_v2" allow-null="false"/>
|
||||
</request>
|
||||
|
||||
<request name="set_priority" since="3">
|
||||
<description summary="Set the order of outputs">
|
||||
Set the position of the output in the output order list, with lower values
|
||||
being earlier in the list. There's no specific value the list has to start
|
||||
at, this value is only used in sorting outputs.
|
||||
|
||||
The order of outputs can be used to assign desktop environment components
|
||||
to a specific screen, see kde_output_order_v1 and kde-output-device-v2 for
|
||||
details. Note that for consistent behavior, the priority value needs to be
|
||||
unique among all enabled outputs.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the index applies to" />
|
||||
<arg name="priority" type="uint" summary="the priority of the output" />
|
||||
</request>
|
||||
|
||||
<request name="set_high_dynamic_range" since="4">
|
||||
<description summary="change if HDR should be enabled">
|
||||
Sets whether or not the output should be set to HDR mode.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="enable_hdr" type="uint" summary="1 to enable, 0 to disable hdr"/>
|
||||
</request>
|
||||
|
||||
<request name="set_sdr_brightness" since="4">
|
||||
<description summary="set the brightness for sdr content">
|
||||
Sets the brightness of standard dynamic range content in nits. Only has an effect while the output is in HDR mode.
|
||||
Note that while the value is in nits, that doesn't necessarily translate to the same brightness on the screen.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="sdr_brightness" type="uint"/>
|
||||
</request>
|
||||
|
||||
<request name="set_wide_color_gamut" since="4">
|
||||
<description summary="change if a wide color gamut should be used">
|
||||
Whether or not the output should use a wide color gamut
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="enable_wcg" type="uint" summary="1 to enable, 0 to disable wcg"/>
|
||||
</request>
|
||||
|
||||
<enum name="auto_rotate_policy">
|
||||
<description summary="describes when auto rotate should be used"/>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="in_tablet_mode" value="1"/>
|
||||
<entry name="always" value="2"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_auto_rotate_policy" since="5">
|
||||
<description summary="change when auto rotate should be used"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
|
||||
</request>
|
||||
|
||||
<request name="set_icc_profile_path" since="6">
|
||||
<description summary="change the used icc profile for SDR mode"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="profile_path" type="string"/>
|
||||
</request>
|
||||
|
||||
<request name="set_brightness_overrides" since="7">
|
||||
<description summary="override metadata about the screen's brightness limits"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="max_peak_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
|
||||
<arg name="max_frame_average_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
|
||||
<arg name="min_brightness" type="int" summary="-1 for not overriding, or positive values in 0.0001 nits"/>
|
||||
</request>
|
||||
|
||||
<request name="set_sdr_gamut_wideness" since="7">
|
||||
<description summary="describes which gamut is assumed for sRGB applications">
|
||||
This can be used to provide the colors users assume sRGB applications should have based on the
|
||||
default experience on many modern sRGB screens.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
|
||||
</request>
|
||||
|
||||
<enum name="color_profile_source" since="7">
|
||||
<description summary="which source the compositor should use for the color profile on an output"/>
|
||||
<entry name="sRGB" value="0"/>
|
||||
<entry name="ICC" value="1"/>
|
||||
<entry name="EDID" value="2"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_color_profile_source" since="8">
|
||||
<description summary="which source the compositor should use for the color profile on an output in SDR mode"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
|
||||
</request>
|
||||
|
||||
<request name="set_brightness" since="9">
|
||||
<description summary="brightness multiplier">
|
||||
Set the brightness modifier of the output. It doesn't specify
|
||||
any absolute values, but is merely a multiplier on top of other
|
||||
brightness values, like sdr_brightness and brightness_metadata.
|
||||
0 is the minimum brightness (not completely dark) and 10000 is
|
||||
the maximum brightness.
|
||||
This is supported while HDR is active in versions 8 and below,
|
||||
or when the device supports the "brightness" capability in
|
||||
versions 9 and above.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
|
||||
</request>
|
||||
|
||||
<enum name="color_power_tradeoff">
|
||||
<description summary="tradeoff between power and accuracy">
|
||||
The compositor can do a lot of things that trade between
|
||||
performance, power and color accuracy. This setting describes
|
||||
a high level preference from the user about in which direction
|
||||
that tradeoff should be made.
|
||||
</description>
|
||||
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
|
||||
<entry name="accuracy" value="1" summary="prefer accuracy"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_color_power_tradeoff" since="10">
|
||||
<description summary="set the preferred color/power tradeoff"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
|
||||
</request>
|
||||
|
||||
<request name="set_dimming" since="11">
|
||||
<description summary="dimming multiplier">
|
||||
Set the dimming multiplier of the output. This is similar to the
|
||||
brightness setting, except it's meant to be a temporary setting
|
||||
only, not persistent and may be implemented differently depending
|
||||
on the display.
|
||||
0 is the minimum dimming factor (not completely dark) and 10000
|
||||
means the output is not dimmed.
|
||||
|
||||
This is supported only when the "brightness" capability is
|
||||
also supported.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
|
||||
</request>
|
||||
|
||||
<event name="failure_reason" since="12">
|
||||
<description summary="reason for failure">
|
||||
Describes why applying the output configuration failed. Is only
|
||||
sent before the failure event.
|
||||
</description>
|
||||
<arg name="reason" type="string" summary="reason for failure"/>
|
||||
</event>
|
||||
|
||||
<request name="set_replication_source" since="13">
|
||||
<description summary="source output for mirroring">
|
||||
Set the source output that the outputdevice should mirror its
|
||||
viewport from.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="source" type="string" summary="uuid of the source output"/>
|
||||
</request>
|
||||
|
||||
<request name="set_ddc_ci_allowed" since="14">
|
||||
<description summary="if DDC/CI should be used to control brightness etc."/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
|
||||
</request>
|
||||
|
||||
<request name="set_max_bits_per_color" since="15">
|
||||
<description summary="override the max bpc">
|
||||
This limits the amount of bits per color that are sent to the display.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
|
||||
</request>
|
||||
|
||||
<enum name="edr_policy" since="16">
|
||||
<description summary="when the compositor may make use of EDR"/>
|
||||
<entry name="never" value="0"/>
|
||||
<entry name="always" value="1"/>
|
||||
</enum>
|
||||
|
||||
<request name="set_edr_policy" since="16">
|
||||
<description summary="set when the compositor may apply EDR">
|
||||
When EDR is enabled, the compositor may increase the backlight beyond
|
||||
the user-specified setting, in order to present HDR content on displays
|
||||
without native HDR support.
|
||||
This will usually result in better visuals, but also increases battery
|
||||
usage.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="policy" type="uint" enum="edr_policy"/>
|
||||
</request>
|
||||
|
||||
<request name="set_sharpness" since="17">
|
||||
<description summary="sharpness strength">
|
||||
This is the sharpness modifier of the output.
|
||||
0 is sharpness disabled and 10000 is the maximum sharpness
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
|
||||
</request>
|
||||
|
||||
<request name="set_custom_modes" since="18">
|
||||
<description summary="set the custom mode list">
|
||||
Set the list of custom modes for this output. The compositor
|
||||
will in response generate the requested modes and add them to
|
||||
the output (or delete ones no longer in the list).
|
||||
This can be useful for overclocking displays, or for working
|
||||
around broken EDIDs.
|
||||
Note that there is no guarantee for any custom mode to
|
||||
actually work, or even to leave the display undamaged (in the
|
||||
case of CRTs). It's entirely the responsibility of the user
|
||||
to ensure each added mode is the right one for their display.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="modes" type="object" interface="kde_mode_list_v2"/>
|
||||
</request>
|
||||
|
||||
<request name="set_auto_brightness" since="19">
|
||||
<description summary="whether or not automatic brightness is enabled"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
|
||||
</request>
|
||||
|
||||
<request name="set_hdr_icc_profile_path" since="20">
|
||||
<description summary="change the used icc profile for HDR mode"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="profile_path" type="string"/>
|
||||
</request>
|
||||
|
||||
<request name="set_hdr_color_profile_source" since="20">
|
||||
<description summary="which source the compositor should use for the color profile on an output in HDR mode"/>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
|
||||
</request>
|
||||
|
||||
<request name="set_abm_level" since="21">
|
||||
<description summary="set the allowed level of adaptive backlight modulation">
|
||||
Adaptive backlight modulation is a feature that reduces the backlight
|
||||
and increases contrast of colors on the screen to improve power usage.
|
||||
</description>
|
||||
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
|
||||
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="kde_mode_list_v2" version="22">
|
||||
<description summary="a list of custom modes">
|
||||
This list is populated by first setting each relevant property,
|
||||
and then calling add_mode to add a mode with these properties.
|
||||
One would for example call
|
||||
- set_resolution
|
||||
- set_refresh_rate
|
||||
- set_reduced_blanking
|
||||
- add_mode
|
||||
|
||||
add_mode does not reset the properties that were previously set,
|
||||
they are valid until the object is destroyed.
|
||||
The compositor may additionally have sensible defaults for some
|
||||
properties like reduced_blanking, but for consistent results,
|
||||
it's best to always set each known property every time.
|
||||
|
||||
One can also specify custom modes with CVT timings, for example
|
||||
- add_cvt
|
||||
- add_cvt
|
||||
|
||||
The parameters resolution and refresh rate are required, if they
|
||||
are not set, the missing_parameters error will be emitted.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<description summary="kde_mode_list_v2 error values">
|
||||
These errors can be emitted in response to add_mode requests.
|
||||
</description>
|
||||
<entry name="missing_parameters" value="0" summary="a required parameter wasn't set"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the mode list object"/>
|
||||
</request>
|
||||
|
||||
<request name="add_mode">
|
||||
<description summary="Add the current mode configuration to the list"/>
|
||||
</request>
|
||||
|
||||
<request name="set_resolution">
|
||||
<arg name="width" type="uint"/>
|
||||
<arg name="height" type="uint"/>
|
||||
</request>
|
||||
|
||||
<request name="set_refresh_rate">
|
||||
<arg name="rate" type="uint" summary="in milliHz"/>
|
||||
</request>
|
||||
|
||||
<request name="set_reduced_blanking">
|
||||
<description summary="whether or not the mode should have reduced blanking">
|
||||
Reduced blanking is an optimization that can reduce bandwidth / timing
|
||||
requirements for a display mode by reducing the time vblank takes.
|
||||
As not all displays support it, it may be desired to still turn it off
|
||||
though (like with CRTs, where full blanking is required).
|
||||
</description>
|
||||
<arg name="reduced" type="uint"
|
||||
summary="1 for reduced blanking, 0 for normal vblank duration"/>
|
||||
</request>
|
||||
|
||||
<request name="add_cvt" since="22">
|
||||
<description summary="new mode with CVT timings">
|
||||
Adds a new mode with the specified CVT timings.
|
||||
</description>
|
||||
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
|
||||
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
|
||||
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
|
||||
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
|
||||
<arg name="htotal" type="uint" summary="horizontal total size"/>
|
||||
<arg name="hskew" type="uint" summary="horizontal skew"/>
|
||||
<arg name="vdisplay" type="uint" summary="vertical display size"/>
|
||||
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
|
||||
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
|
||||
<arg name="vtotal" type="uint" summary="vertical total size"/>
|
||||
<arg name="vscan" type="uint" summary="vertical scan"/>
|
||||
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
|
||||
</request>
|
||||
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -58,6 +58,11 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
|
||||
pub(crate) mod backend;
|
||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
|
||||
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
|
||||
/// can wedge the calling (session) thread forever.
|
||||
#[path = "vdisplay/proc.rs"]
|
||||
pub(crate) mod proc;
|
||||
|
||||
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
||||
#[path = "vdisplay/session.rs"]
|
||||
pub(crate) mod session;
|
||||
@@ -440,6 +445,13 @@ mod hyprland;
|
||||
#[path = "vdisplay/linux/kwin.rs"]
|
||||
mod kwin;
|
||||
|
||||
// In-process KDE output management (kde_output_management_v2) — the topology path that used to shell
|
||||
// out to `kscreen-doctor`, driven over the compositor's own Wayland instead so it can't be wedged by
|
||||
// a stuck libkscreen/kscreen-KDED backend. Consumed by `kwin` (best-effort, with kscreen fallback).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub mod manager;
|
||||
|
||||
@@ -61,6 +61,32 @@ static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mute
|
||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The display-manager unit we stopped for the takeover on a mask-fragile DM flavor (Nobara's
|
||||
/// `plasmalogin` — see [`dm_survives_masked_unit`]), so the restore brings the box back via
|
||||
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||
/// cannot work there: without a DM login session there is no seat, so gamescope never gets DRM
|
||||
/// master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
|
||||
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||
/// therefore the one durable trace of the user's switch request.
|
||||
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
|
||||
/// the rebuild loop would otherwise race the booting desktop back into game mode (gamescope+Steam
|
||||
/// come up faster than KWin, and a delivering managed pipeline ends the rebuild's re-detection).
|
||||
static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// How long after honoring an in-stream desktop switch the managed path refuses to relaunch,
|
||||
/// giving the DM's desktop session time to come up so re-detection follows it instead.
|
||||
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||
@@ -115,6 +141,10 @@ struct TakeoverState {
|
||||
stopped_autologin: Vec<String>,
|
||||
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
||||
steamos: bool,
|
||||
/// The display-manager unit we stopped on a mask-fragile DM flavor (restore = `reset-failed` +
|
||||
/// `restart` of the DM). `default` so takeover files from older hosts still parse.
|
||||
#[serde(default)]
|
||||
stopped_dm: Option<String>,
|
||||
}
|
||||
|
||||
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
||||
@@ -133,8 +163,9 @@ fn persist_takeover() {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
stopped_dm: STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
};
|
||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
||||
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
@@ -162,17 +193,22 @@ pub fn restore_takeover_on_startup() {
|
||||
clear_takeover();
|
||||
return;
|
||||
};
|
||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
||||
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
units = ?state.stopped_autologin,
|
||||
steamos = state.steamos,
|
||||
stopped_dm = ?state.stopped_dm,
|
||||
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
||||
);
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm;
|
||||
// Re-baseline the session-select sentinel: after a crash-restore the launch-time baseline is
|
||||
// gone, and a long-existing sentinel file must not read as a fresh in-stream switch request.
|
||||
record_session_select_baseline();
|
||||
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
||||
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
@@ -263,7 +299,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||
stop_autologin_sessions();
|
||||
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
|
||||
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
|
||||
stop_autologin_sessions()
|
||||
.context("dedicated Steam launch needs the box's gaming session freed")?;
|
||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||
free_desktop_steam()?;
|
||||
@@ -326,6 +365,40 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode);
|
||||
}
|
||||
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(dm) = dm_takeover {
|
||||
if session_select_requested() {
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
honor_session_select_switch(dm);
|
||||
return Err(anyhow!(
|
||||
"the user switched the box to the desktop session — display manager restored; \
|
||||
re-detection follows the desktop compositor as it comes up"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||
// re-detection — right back in game mode. A live box-owned game-mode unit supersedes the
|
||||
// grace: the user already switched back, so managed may proceed.
|
||||
let honor_pending = SWITCH_HONORED_AT
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some_and(|t| t.elapsed() < SWITCH_HONOR_GRACE);
|
||||
if honor_pending {
|
||||
if running_autologin_gamescope_unit().is_some() {
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"waiting for the desktop session the user selected — refusing to relaunch game \
|
||||
mode (re-detection follows the desktop once it's up)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||
// destructive rebuild here would fight the session the user just switched to.
|
||||
@@ -353,7 +426,28 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||
stop_autologin_sessions();
|
||||
// On a mask-fragile-DM box without the privilege to stop the DM, the takeover would destabilize
|
||||
// the seat — degrade to ATTACH instead: mirror the box's own live game-mode session (capture +
|
||||
// inject, no lifecycle ownership), which needs no takeover at all.
|
||||
if let Err(e) = stop_autologin_sessions() {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
|
||||
own game-mode session)"
|
||||
);
|
||||
let node_id = ensure_box_gamescope_mode(mode)?;
|
||||
point_injector_at_eis();
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
ownership: DisplayOwnership::External,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
});
|
||||
}
|
||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
||||
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
||||
@@ -379,7 +473,21 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
}
|
||||
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
||||
// exactly one gamescope `Video/Source` node for discovery.
|
||||
let node_id = launch_session(client, SESSION_UNIT, mode)?;
|
||||
let node_id = match launch_session(client, SESSION_UNIT, mode) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// The takeover already happened (autologin units stopped, possibly the DM down) — arm
|
||||
// the restore now, or a failed launch strands the box sessionless until a host
|
||||
// restart. Policy-timed; a quick client retry cancels it and relaunches warm.
|
||||
// MANAGED_SESSION must be released first: the scheduler reads it (orphan detection).
|
||||
drop(guard);
|
||||
schedule_restore_tv_session();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Baseline the session-select sentinel NOW: only a write from INSIDE this session (the user's
|
||||
// "Switch to Desktop") should read as a switch request, not the one that led here.
|
||||
record_session_select_baseline();
|
||||
point_injector_at_eis();
|
||||
*guard = Some(SessionState {
|
||||
width: mode.width,
|
||||
@@ -807,6 +915,38 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
|
||||
return Ok(node);
|
||||
}
|
||||
}
|
||||
// Attach-only rebuild probe (parity with both managed paths — this gap was the attach-path
|
||||
// stale-detection hazard): right after a capture loss the caller's session detection can be
|
||||
// stale, and a set-environment + unit restart here would fight the session the user just
|
||||
// switched to. Mirror whatever live node exists at its own mode; refuse otherwise.
|
||||
if crate::rebuild_probe_active() {
|
||||
if let Some(node) = find_gamescope_node() {
|
||||
tracing::info!(
|
||||
node,
|
||||
"gamescope: attach-only rebuild probe — mirroring the live node at its own mode"
|
||||
);
|
||||
return Ok(node);
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"no live gamescope node — attach-only rebuild probe refuses to restart the box's \
|
||||
session (re-detection follows the live session)"
|
||||
));
|
||||
}
|
||||
// A box driving a PHYSICAL display is mirrored at its own mode, never re-moded: the re-mode
|
||||
// restart is the headless-box model (no panel ⇒ the game-mode resolution is ours to set);
|
||||
// on-glass it would flip the user's own screen to the client's resolution — and on a
|
||||
// DM-session-driven box (Nobara) the unit restart bounces the login session with it.
|
||||
if physical_display_connected() {
|
||||
if let Some(node) = find_gamescope_node() {
|
||||
tracing::info!(
|
||||
node,
|
||||
client_w = mode.width,
|
||||
client_h = mode.height,
|
||||
"gamescope: box drives a physical display — attaching at its own mode (no re-mode)"
|
||||
);
|
||||
return Ok(node);
|
||||
}
|
||||
}
|
||||
let Some(unit) = running_autologin_gamescope_unit() else {
|
||||
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
|
||||
// whatever node exists, accepting its resolution.
|
||||
@@ -972,17 +1112,245 @@ fn unmask_unit(unit: &str) {
|
||||
.status();
|
||||
}
|
||||
|
||||
/// The unit name of the display manager driving this box's graphical logins, from the
|
||||
/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every
|
||||
/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight
|
||||
/// into a user session — getty autologin / an enabled user unit).
|
||||
fn display_manager_unit() -> Option<String> {
|
||||
display_manager_unit_under(std::path::Path::new("/etc/systemd/system"))
|
||||
}
|
||||
|
||||
/// [`display_manager_unit`] against an arbitrary root (the unit-testable core).
|
||||
fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
|
||||
let target = std::fs::read_link(base.join("display-manager.service")).ok()?;
|
||||
target.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? Only SDDM is
|
||||
/// proven to (Bazzite/SteamOS `Relogin=true` — the mask is what stops its relogin from restarting
|
||||
/// the unit mid-stream, diagnosed live on .181 2026-07-07; a failing autologin leaves sddm itself
|
||||
/// running). Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
|
||||
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
|
||||
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
|
||||
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
|
||||
/// "safe" kills the seat.
|
||||
fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
|
||||
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
|
||||
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
|
||||
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
|
||||
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
|
||||
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
|
||||
/// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
|
||||
/// PKGBUILD rewrites the annotation to match).
|
||||
const DM_HELPER_PATHS: &[&str] = &[
|
||||
"/usr/libexec/punktfunk/pf-dm-helper",
|
||||
"/usr/lib/punktfunk/pf-dm-helper",
|
||||
];
|
||||
|
||||
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
fn dm_helper(verb: &str) -> bool {
|
||||
let Some(helper) = DM_HELPER_PATHS
|
||||
.iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Command::new("pkexec")
|
||||
.arg(helper)
|
||||
.arg(verb)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||
/// attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||
/// two steps as root.
|
||||
fn restore_display_manager(dm: &str) -> bool {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", dm])
|
||||
.status();
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["restart", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("restore")
|
||||
}
|
||||
|
||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||
/// only while the DM is RUNNING, which is why the takeover must restart the DM before calling it.
|
||||
const OS_SESSION_SELECT: &str = "/usr/libexec/os-session-select";
|
||||
|
||||
/// The sentinel Steam's `steamos-session-select` writes in its user pass
|
||||
/// (`~/.config/steamos-session-select`) — see [`SESSION_SELECT_BASELINE`].
|
||||
fn session_select_sentinel() -> Option<std::path::PathBuf> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
Some(
|
||||
std::path::Path::new(&home)
|
||||
.join(".config")
|
||||
.join("steamos-session-select"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Current mtime of the session-select sentinel (`None` when it doesn't exist yet).
|
||||
fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
let path = session_select_sentinel()?;
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
|
||||
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
|
||||
}
|
||||
|
||||
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
|
||||
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
|
||||
/// offers is TO the desktop, so an advanced sentinel reads as that request.
|
||||
fn session_select_requested() -> bool {
|
||||
let baseline = *SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
match (baseline, session_select_mtime()) {
|
||||
(Some(base), Some(now)) => now > base,
|
||||
(None, Some(_)) => true, // created during the session
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Honor the user's in-stream "Switch to Desktop" under a DM-stop takeover. The OS flow was a
|
||||
/// silent no-op (the switch script's config rewrite requires a running DM, which the takeover
|
||||
/// stopped), so replay it with the DM up — every verb live-validated on the Nobara repro VM:
|
||||
/// 1. consume the takeover and start the DM (its autologin heads back into game mode briefly —
|
||||
/// the config still names it);
|
||||
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||
/// into the now-selected desktop.
|
||||
///
|
||||
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||
fn honor_session_select_switch(dm: String) {
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||
switching the box to the desktop session"
|
||||
);
|
||||
// Consume the takeover state up front: from here on the box is the DM's again.
|
||||
std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
if !restore_display_manager(&dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: display-manager start was denied — the desktop switch may need a manual \
|
||||
`systemctl restart` of the DM"
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let active = Command::new("systemctl")
|
||||
.args(["is-active", &dm])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
.unwrap_or(false);
|
||||
if active {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
// Rewrite the autologin session via the distro's own switch helper (needs the DM running).
|
||||
// Absent/failing helper degrades to a plain DM restore — the box lands back in game mode on
|
||||
// glass and the stream follows that instead (no black screen either way).
|
||||
if std::path::Path::new(OS_SESSION_SELECT).exists() {
|
||||
match Command::new(OS_SESSION_SELECT).arg("desktop").status() {
|
||||
Ok(s) if s.success() => {
|
||||
// The relogin only fires when the CURRENT (game-mode) login session exits: wait
|
||||
// for its autologin unit to come up, then stop it. Never mask here — the mask is
|
||||
// what start-limit-kills this DM flavor.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
if let Some(unit) = running_autologin_gamescope_unit() {
|
||||
systemctl_user(&["stop", &unit]);
|
||||
tracing::info!(
|
||||
%unit,
|
||||
"gamescope: desktop selected — stopped the game-mode session so the \
|
||||
DM relogs into the desktop"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(
|
||||
"gamescope: game-mode session never appeared after the DM restart — \
|
||||
the desktop switch may need a manual session exit"
|
||||
);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
other => tracing::warn!(
|
||||
status = ?other,
|
||||
"gamescope: os-session-select failed — leaving the box in its configured session"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"gamescope: no {OS_SESSION_SELECT} on this box — restored the DM into its configured \
|
||||
session instead of switching to the desktop"
|
||||
);
|
||||
}
|
||||
record_session_select_baseline();
|
||||
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] —
|
||||
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
|
||||
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
||||
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
|
||||
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
|
||||
/// fight the moment the supervisor restarts it.
|
||||
fn stop_autologin_sessions() {
|
||||
/// is autologged in (e.g. a box that boots headless).
|
||||
///
|
||||
/// The teardown is DM-flavor-aware ([`dm_survives_masked_unit`]):
|
||||
/// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — SDDM's `Relogin=true` would
|
||||
/// otherwise restart it instantly), then torn down with **SIGKILL** ([`kill_unit`]) to avoid the
|
||||
/// F44 GPU-context leak that the autologin's SIGTERM stop triggers. Matches every loaded
|
||||
/// instance, not just `running` ones — under the SDDM relogin churn the unit flaps through
|
||||
/// `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the fight the
|
||||
/// moment the supervisor restarts it.
|
||||
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
|
||||
/// itself (permanent black screen), so instead **stop the DM** — no supervisor left to relogin —
|
||||
/// then SIGKILL the units unmasked. Needs privilege (root / an operator polkit rule); without it
|
||||
/// nothing is touched and the error tells the caller to degrade to ATTACH (mirror the box's own
|
||||
/// session) rather than destabilize the seat.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
@@ -995,26 +1363,67 @@ fn stop_autologin_sessions() {
|
||||
])
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let mut stopped = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Some(unit) = line.split_whitespace().next() {
|
||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
||||
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
tracing::info!(
|
||||
unit,
|
||||
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
|
||||
);
|
||||
stopped.push(unit.to_string());
|
||||
}
|
||||
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect();
|
||||
if listed.is_empty() {
|
||||
return Ok(()); // nothing autologged in — Steam is already free
|
||||
}
|
||||
let dm = display_manager_unit();
|
||||
let mask_safe = dm.as_deref().is_none_or(dm_survives_masked_unit);
|
||||
if !mask_safe {
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
|
||||
// would kill the user's live desktop to free nothing.
|
||||
if !listed
|
||||
.iter()
|
||||
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let dm = dm.expect("!is_none_or ⇒ Some");
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
session unit, and stopping it needs privilege — the packaged pf-dm-helper \
|
||||
polkit action is missing or was denied (reinstall the punktfunk package, or \
|
||||
install the display-manager polkit rule from the docs) so the managed takeover \
|
||||
is unavailable"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
|
||||
);
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
}
|
||||
if !stopped.is_empty() {
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
for unit in units {
|
||||
if mask_safe {
|
||||
mask_unit(&unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||
}
|
||||
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
tracing::info!(
|
||||
%unit,
|
||||
masked = mask_safe,
|
||||
"freed Steam: stopped the autologin gaming session for this stream"
|
||||
);
|
||||
stopped.push(unit);
|
||||
}
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||
@@ -1159,7 +1568,16 @@ pub fn schedule_restore_tv_session() {
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
|
||||
// A managed session that took nothing over (started beside a live desktop — e.g. a client
|
||||
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
|
||||
// was ORPHANED forever after disconnect ("closing the app does not end the session",
|
||||
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|
||||
&& MANAGED_SESSION
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_none();
|
||||
if nothing_to_restore {
|
||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||
}
|
||||
@@ -1254,8 +1672,24 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
if units.is_empty() {
|
||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
||||
let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
if units.is_empty() && dm.is_none() {
|
||||
// Nothing was stolen — but a managed session that started BESIDE a live desktop (client
|
||||
// gamescope pin on a KDE box) still owns the transient unit; stop it so it doesn't run
|
||||
// orphaned forever after the disconnect. No-op when the unit isn't running.
|
||||
if MANAGED_SESSION
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
.is_some()
|
||||
{
|
||||
stop_session(SESSION_UNIT);
|
||||
tracing::info!(
|
||||
"gamescope: stopped the idle managed session (nothing was taken over — no box \
|
||||
session to restore)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||
@@ -1266,18 +1700,47 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
||||
// to gaming — leave the desktop alone. (We still stopped our idle managed session above.)
|
||||
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||
// them back to gaming — leave the desktop alone. (We still stopped our idle managed session
|
||||
// above.)
|
||||
use super::ActiveKind;
|
||||
if matches!(
|
||||
super::detect_active_session().kind,
|
||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
||||
ActiveKind::DesktopKde
|
||||
| ActiveKind::DesktopGnome
|
||||
| ActiveKind::DesktopWlroots
|
||||
| ActiveKind::DesktopHyprland
|
||||
) {
|
||||
tracing::info!(
|
||||
"gamescope: a desktop session is active — not restoring the TV gaming session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Mask-fragile-DM takeover: the gamescope unit CANNOT be `--user start`ed back — without a DM
|
||||
// login session there is no seat, so gamescope never gets DRM master (unit goes `failed`,
|
||||
// screen stays black — live-proven on the Nobara repro VM). Restore the DM instead
|
||||
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||
// session Exec starts the gamescope unit itself.
|
||||
if let Some(dm) = dm {
|
||||
let restart = restore_display_manager(&dm);
|
||||
if restart {
|
||||
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||
} else if crate::try_recover_session() {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
|
||||
to bring the session back"
|
||||
);
|
||||
} else {
|
||||
tracing::error!(
|
||||
%dm,
|
||||
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
|
||||
configured — the box has no graphical session until someone runs \
|
||||
`systemctl reset-failed {dm} && systemctl restart {dm}` as root"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for unit in units {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "start", &unit])
|
||||
@@ -1641,10 +2104,35 @@ impl Drop for GamescopeProc {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
|
||||
shape_dedicated_command,
|
||||
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
||||
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn display_manager_flavor_detection() {
|
||||
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// No alias symlink (no DM installed — getty autologin boxes) → None.
|
||||
assert_eq!(display_manager_unit_under(&base), None);
|
||||
// The Fedora-style alias symlink resolves to its target's basename (read_link, not
|
||||
// canonicalize — the target needn't exist on the build box).
|
||||
std::os::unix::fs::symlink(
|
||||
"/usr/lib/systemd/system/plasmalogin.service",
|
||||
base.join("display-manager.service"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
display_manager_unit_under(&base).as_deref(),
|
||||
Some("plasmalogin.service")
|
||||
);
|
||||
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
|
||||
// itself (live-proven), and unknown DMs default to fragile.
|
||||
assert!(dm_survives_masked_unit("sddm.service"));
|
||||
assert!(!dm_survives_masked_unit("plasmalogin.service"));
|
||||
assert!(!dm_survives_masked_unit("gdm.service"));
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_status_scan() {
|
||||
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||
|
||||
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
|
||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||
/// gate. Returns the parsed version when it could read one.
|
||||
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
|
||||
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
|
||||
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
|
||||
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
|
||||
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
|
||||
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
|
||||
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
|
||||
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
|
||||
|
||||
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
|
||||
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
|
||||
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
|
||||
/// diagnostic, not a gate. Returns the parsed version when it could read one.
|
||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||
);
|
||||
} else if ver < MIN_GAMESCOPE_OVERLAY {
|
||||
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
|
||||
tracing::warn!(
|
||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||
min = %format!(
|
||||
"{}.{}.{}",
|
||||
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
|
||||
),
|
||||
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
|
||||
Quick Access Menu) into its PipeWire node — the overlay will be absent from the \
|
||||
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
|
||||
);
|
||||
}
|
||||
Some(ver)
|
||||
}
|
||||
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
|
||||
|
||||
#[test]
|
||||
fn parses_steam_appid_from_launch() {
|
||||
@@ -425,4 +447,15 @@ mod tests {
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_threshold_brackets_the_fix() {
|
||||
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
|
||||
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
|
||||
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ pub struct KwinDisplay {
|
||||
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
|
||||
/// superseded same-name sibling is still alive.
|
||||
last_name: Option<String>,
|
||||
/// The RESOLVED `kde_output_device_v2` UUID of the last `create`'s output, when the in-process
|
||||
/// output-management path handled the topology. A stable per-output id (unlike the shared name),
|
||||
/// so [`apply_position`](VirtualDisplay::apply_position) and restore address exactly OUR output
|
||||
/// across a supersede — preferred over `last_name`, which is only the kscreen-doctor fallback.
|
||||
our_uuid: Option<String>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
||||
@@ -114,6 +119,48 @@ impl KwinDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(KwinDisplay::default())
|
||||
}
|
||||
|
||||
/// Apply the effective display topology for the just-created output `our_prefix` (current size
|
||||
/// `dims`), preferring the in-process `kde_output_management_v2` path and falling back to
|
||||
/// `kscreen-doctor` if the compositor doesn't answer in budget or the management global is
|
||||
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
|
||||
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
|
||||
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
|
||||
fn apply_topology(
|
||||
&mut self,
|
||||
name: &str,
|
||||
our_prefix: &str,
|
||||
dims: (u32, u32),
|
||||
) -> Vec<(String, String)> {
|
||||
use crate::kwin_output_mgmt::TopologyKind;
|
||||
use crate::policy::Topology;
|
||||
let topology = crate::effective_topology();
|
||||
let kind = match topology {
|
||||
Topology::Exclusive => TopologyKind::Exclusive,
|
||||
Topology::Primary => TopologyKind::Primary,
|
||||
Topology::Extend | Topology::Auto => return Vec::new(),
|
||||
};
|
||||
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
|
||||
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||
if outcome.handled {
|
||||
self.our_uuid = outcome.our_uuid;
|
||||
return outcome.disabled;
|
||||
}
|
||||
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
|
||||
tracing::info!(
|
||||
"KWin topology: kde_output_management unavailable — kscreen-doctor fallback"
|
||||
);
|
||||
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
|
||||
self.last_name = Some(addr.clone());
|
||||
match topology {
|
||||
Topology::Exclusive => apply_virtual_primary(&addr),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&addr);
|
||||
Vec::new()
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for KwinDisplay {
|
||||
@@ -142,18 +189,21 @@ impl VirtualDisplay for KwinDisplay {
|
||||
}
|
||||
|
||||
fn apply_position(&mut self, x: i32, y: i32) {
|
||||
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the
|
||||
// `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
|
||||
// Prefer the in-process path: address OUR output by its stable UUID (supersede-robust) over
|
||||
// kde_output_management_v2 — immune to a wedged kscreen-doctor backend (see kwin_output_mgmt).
|
||||
if let Some(uuid) = self.our_uuid.clone() {
|
||||
if crate::kwin_output_mgmt::set_position(&uuid, x, y) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: kscreen-doctor. `last_name` holds the RESOLVED kscreen address (numeric output id,
|
||||
// or the `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
|
||||
// outputs share it and the command would hit the old one (see `create`).
|
||||
let Some(output) = self.last_name.clone() else {
|
||||
return;
|
||||
};
|
||||
// kscreen-doctor position syntax: `output.<name-or-id>.position.<x>,<y>`.
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{output}.position.{x},{y}"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let ok = kscreen_ok(&[format!("output.{output}.position.{x},{y}")]);
|
||||
if ok {
|
||||
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
||||
} else {
|
||||
@@ -210,14 +260,16 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// resize handling: when the source's texture size changes while recording, KWin re-runs
|
||||
// `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live
|
||||
// stream via `pw_stream_update_params`. So above 60 Hz the output is born at a
|
||||
// SACRIFICIAL height: installing + selecting the real `WxH@hz` custom mode (supported on
|
||||
// virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
||||
// after the consumer connects trigger KWin's resize → a renegotiation to `WxH@hz`. The
|
||||
// SACRIFICIAL height: installing + selecting the real high-refresh custom mode (supported
|
||||
// on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
||||
// after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
|
||||
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
|
||||
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
|
||||
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
|
||||
// KWin *actually* gave so the encoder paces to the real source rate. At ≤60 Hz there's
|
||||
// nothing to install — the output is born at the real size and 60 Hz is the offer anyway.
|
||||
// builds against the birth mode. The install/select runs in-process over
|
||||
// kde_output_management_v2 (`kwin_output_mgmt::set_custom_mode`), with kscreen-doctor
|
||||
// (`set_custom_refresh`) as the fallback; either reads back what KWin *actually* gave — both
|
||||
// the rate (so the encoder paces to the real source) and the size, which KWin's CVT
|
||||
// generator may have aligned down (see `CVT_H_GRANULARITY`). At ≤60 Hz there's nothing to
|
||||
// install — the output is born at the real size and 60 Hz is the offer anyway.
|
||||
let want_high = mode.refresh_hz > 60;
|
||||
let birth_h = if want_high { height + 16 } else { height };
|
||||
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
||||
@@ -229,48 +281,75 @@ impl VirtualDisplay for KwinDisplay {
|
||||
embedded_pointer = !self.hw_cursor,
|
||||
"KWin virtual output ready"
|
||||
);
|
||||
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
|
||||
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config
|
||||
// persistence — while the superseded sibling is still alive (create-before-drop). Every
|
||||
// name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this
|
||||
// resized the LIVE session's display out from under it (wrong-res/black), read back the
|
||||
// OLD output as "custom mode applied", made the OLD output primary, and positioned it —
|
||||
// while the new output never left its birth mode and the capturer's dims gate starved.
|
||||
// Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode ==
|
||||
// the just-created birth size; newest id wins), and use it for every kscreen operation.
|
||||
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
|
||||
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
|
||||
// Topology + positioning address OUR output by its kde_output_management UUID (resolved
|
||||
// in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
|
||||
// the path never shells out. `Virtual-<name>` is the name KWin exposes our output as.
|
||||
let our_prefix = format!("Virtual-{name}");
|
||||
let mut expect_exact_dims = false;
|
||||
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
|
||||
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
|
||||
// `preferred_mode`, which is what the capturer's renegotiation gate waits for and what the
|
||||
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
|
||||
let mut final_dims = (width, height);
|
||||
let achieved_hz = if want_high {
|
||||
let (achieved, size_applied) =
|
||||
set_custom_refresh(width, height, mode.refresh_hz, &addr);
|
||||
if size_applied {
|
||||
// Real mode selected: the recording stream will renegotiate to it (see above).
|
||||
expect_exact_dims = true;
|
||||
achieved
|
||||
} else {
|
||||
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
|
||||
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
|
||||
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
|
||||
tracing::warn!(
|
||||
"KWin rejected the custom mode — recreating the virtual output at the real \
|
||||
size (60 Hz ceiling on this KWin)"
|
||||
);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
// Let KWin retire the doomed output before re-using its name.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let (nid, st) = spawn_vout(width, height)?;
|
||||
node_id = nid;
|
||||
stop = st;
|
||||
addr = resolve_kscreen_addr(&name, width, height);
|
||||
// >60 Hz needs the real high-refresh custom mode installed + selected (sacrificial-birth,
|
||||
// see above). In-process over kde_output_management_v2 first (no kscreen-doctor); fall
|
||||
// back to the kscreen-doctor shell-out on pre-6.6 KWin (no `set_custom_modes`) or if the
|
||||
// compositor doesn't answer in budget.
|
||||
let active = crate::kwin_output_mgmt::set_custom_mode(
|
||||
&our_prefix,
|
||||
width,
|
||||
birth_h,
|
||||
width,
|
||||
height,
|
||||
mode.refresh_hz,
|
||||
)
|
||||
.or_else(|| {
|
||||
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede reuses the per-slot
|
||||
// name while the superseded sibling is still alive, so a name-addressed kscreen
|
||||
// command hits the OLD output. Resolve our kscreen id for the shell-out install +
|
||||
// keep it as apply_position's kscreen fallback.
|
||||
let addr = resolve_kscreen_addr(&name, width, birth_h);
|
||||
self.last_name = Some(addr.clone());
|
||||
tracing::info!(
|
||||
node_id,
|
||||
width,
|
||||
height,
|
||||
"KWin virtual output ready (fallback)"
|
||||
);
|
||||
60
|
||||
set_custom_refresh(width, height, mode.refresh_hz, &addr)
|
||||
});
|
||||
// Accept only an active mode that IS our custom one: the exact requested height, and a
|
||||
// width at or just below the request (a CVT alignment). That also proves the output
|
||||
// left the sacrificial birth size, so the recording stream will renegotiate to it.
|
||||
match active {
|
||||
Some((aw, ah, ahz))
|
||||
if ah == height && aw <= width && width - aw < CVT_H_GRANULARITY =>
|
||||
{
|
||||
expect_exact_dims = true;
|
||||
final_dims = (aw, ah);
|
||||
ahz
|
||||
}
|
||||
other => {
|
||||
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
|
||||
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
|
||||
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
|
||||
tracing::warn!(
|
||||
active = ?other,
|
||||
requested_w = width,
|
||||
requested_h = height,
|
||||
requested_hz = mode.refresh_hz,
|
||||
"KWin rejected the custom mode — recreating the virtual output at the real \
|
||||
size (60 Hz ceiling on this KWin)"
|
||||
);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
// Let KWin retire the doomed output before re-using its name.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let (nid, st) = spawn_vout(width, height)?;
|
||||
node_id = nid;
|
||||
stop = st;
|
||||
tracing::info!(
|
||||
node_id,
|
||||
width,
|
||||
height,
|
||||
"KWin virtual output ready (fallback)"
|
||||
);
|
||||
60
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mode.refresh_hz
|
||||
@@ -279,16 +358,16 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||
use crate::policy::Topology;
|
||||
let disabled = match crate::effective_topology() {
|
||||
Topology::Exclusive => apply_virtual_primary(&addr),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&addr);
|
||||
Vec::new() // nothing disabled → nothing to restore
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
};
|
||||
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
|
||||
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
|
||||
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
|
||||
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
|
||||
// A plain managed name is enough for apply_position's kscreen-doctor fallback when the
|
||||
// in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
|
||||
// the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
|
||||
if self.last_name.is_none() {
|
||||
self.last_name = Some(our_prefix);
|
||||
}
|
||||
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
||||
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
||||
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||
@@ -296,13 +375,18 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let disabled = disabled.clone();
|
||||
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
|
||||
Box::new(move || {
|
||||
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
|
||||
reenable_outputs_kscreen(&disabled);
|
||||
}
|
||||
}) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||
let mut out = VirtualOutput::owned(
|
||||
node_id,
|
||||
Some((mode.width, mode.height, achieved_hz)),
|
||||
Some((final_dims.0, final_dims.1, achieved_hz)),
|
||||
Box::new(StopGuard { stop }),
|
||||
);
|
||||
out.expect_exact_dims = expect_exact_dims;
|
||||
@@ -310,10 +394,12 @@ impl VirtualDisplay for KwinDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
||||
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
||||
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||
fn reenable_outputs(outputs: &[(String, String)]) {
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
|
||||
/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
|
||||
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
|
||||
/// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
|
||||
/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||
fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
|
||||
if outputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -324,9 +410,7 @@ fn reenable_outputs(outputs: &[(String, String)]) {
|
||||
.iter()
|
||||
.map(|(name, _)| format!("output.{name}.enable"))
|
||||
.collect();
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&enable_args)
|
||||
.status();
|
||||
let _ = kscreen_ok(&enable_args);
|
||||
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
||||
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
||||
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
||||
@@ -336,9 +420,7 @@ fn reenable_outputs(outputs: &[(String, String)]) {
|
||||
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
||||
.collect();
|
||||
if !mode_args.is_empty() {
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&mode_args)
|
||||
.status();
|
||||
let _ = kscreen_ok(&mode_args);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
||||
@@ -386,13 +468,37 @@ fn resolve_kscreen_addr(name: &str, w: u32, h: u32) -> String {
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Budget for one `kscreen-doctor` call.
|
||||
///
|
||||
/// It is a Wayland client of the very compositor it configures, so against a wedged KWin it blocks
|
||||
/// in its own connect and never returns — and these calls run on the session's stream thread, whose
|
||||
/// only way to end a session is to return. Generous next to a healthy call (tens of ms).
|
||||
const KSCREEN_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// `kscreen-doctor <args>` run for its exit status, bounded by [`KSCREEN_BUDGET`]. A timeout reads
|
||||
/// as a failed apply — the same best-effort path a rejected argument already takes.
|
||||
fn kscreen_ok(args: &[String]) -> bool {
|
||||
crate::proc::status_within(
|
||||
std::process::Command::new("kscreen-doctor").args(args),
|
||||
KSCREEN_BUDGET,
|
||||
)
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `kscreen-doctor -j` stdout, bounded by [`KSCREEN_BUDGET`]; `None` on any failure.
|
||||
fn kscreen_json_bytes() -> Option<Vec<u8>> {
|
||||
crate::proc::output_within(
|
||||
std::process::Command::new("kscreen-doctor").arg("-j"),
|
||||
KSCREEN_BUDGET,
|
||||
)
|
||||
.ok()
|
||||
.map(|o| o.stdout)
|
||||
}
|
||||
|
||||
/// `kscreen-doctor -j` parsed, `None` on any failure.
|
||||
fn kscreen_json() -> Option<serde_json::Value> {
|
||||
let out = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
.ok()?;
|
||||
serde_json::from_slice(&out.stdout).ok()
|
||||
serde_json::from_slice(&kscreen_json_bytes()?).ok()
|
||||
}
|
||||
|
||||
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
|
||||
@@ -415,40 +521,184 @@ fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created
|
||||
/// virtual output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or
|
||||
/// name, see [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode**
|
||||
/// and return `(achieved_hz, size_applied)`. The apply command can report success yet leave the
|
||||
/// output on its old mode (rejected), and a silent rate mismatch surfaces downstream as judder /
|
||||
/// duplicated frames — so the caller paces the encoder to the *achieved* rate, not the requested
|
||||
/// one. `size_applied` tells the sacrificial-birth caller (see `create`) whether the SIZE half of
|
||||
/// the mode actually landed — that, not the refresh, is what triggers KWin's stream
|
||||
/// renegotiation.
|
||||
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, bool) {
|
||||
/// CVT's horizontal cell granularity. KWin generates every custom mode's timing with **libxcvt**,
|
||||
/// whose first step is `hdisplay_rnd = hdisplay - (hdisplay % 8)` — so a width that isn't a multiple
|
||||
/// of 8 comes back NARROWER than asked, and the clock-step rounding that follows lands a fractional
|
||||
/// refresh. A 2868x1320@120 request (an iPhone 16 Pro Max panel) becomes **2864x1320@119.92**.
|
||||
///
|
||||
/// That is why a custom mode must never be selected by the `WxH@Hz` string we *requested*:
|
||||
/// kscreen-doctor's `findMode` matches a mode's id or its own `WxH@qRound(Hz)` name, so
|
||||
/// `2868x1320@120` matches nothing, the select silently no-ops, the output stays on its sacrificial
|
||||
/// birth mode, and the caller falls back to 60 Hz — while KDE's display list shows the perfectly
|
||||
/// good 2864x1320@119.92 mode sitting there unselected. Widths like 1920/2560/3840 are all
|
||||
/// multiples of 8, which is why only phone-shaped clients ever hit it.
|
||||
const CVT_H_GRANULARITY: u32 = 8;
|
||||
|
||||
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct KModeRow {
|
||||
/// kscreen's mode id — what we address the mode by (never the requested `WxH@Hz` string).
|
||||
id: String,
|
||||
w: u32,
|
||||
h: u32,
|
||||
hz: f64,
|
||||
}
|
||||
|
||||
/// A kscreen JSON id, which is a string on some KWin versions and a number on others.
|
||||
fn json_id(v: &serde_json::Value) -> Option<String> {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
}
|
||||
|
||||
/// The full mode list of `output` (a RESOLVED kscreen address — numeric id or name) from a parsed
|
||||
/// `kscreen-doctor -j` document. Split from the process call so the picker can be tested on
|
||||
/// captured JSON.
|
||||
fn modes_from_json(doc: &serde_json::Value, output: &str) -> Vec<KModeRow> {
|
||||
let Some(o) = doc
|
||||
.get("outputs")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|outs| {
|
||||
outs.iter().find(|o| {
|
||||
o.get("name").and_then(|n| n.as_str()) == Some(output)
|
||||
|| o.get("id").and_then(json_id).as_deref() == Some(output)
|
||||
})
|
||||
})
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
o.get("modes")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|ms| {
|
||||
ms.iter()
|
||||
.filter_map(|m| {
|
||||
let size = m.get("size")?;
|
||||
Some(KModeRow {
|
||||
id: m.get("id").and_then(json_id)?,
|
||||
w: size.get("width").and_then(|v| v.as_u64())? as u32,
|
||||
h: size.get("height").and_then(|v| v.as_u64())? as u32,
|
||||
hz: m.get("refreshRate").and_then(|r| r.as_f64())?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`modes_from_json`] against a live `kscreen-doctor -j`.
|
||||
fn output_modes(output: &str) -> Vec<KModeRow> {
|
||||
kscreen_json()
|
||||
.map(|doc| modes_from_json(&doc, output))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The mode in `modes` that actually fulfils a `width`x`height`@`hz` request, tolerating the CVT
|
||||
/// alignment KWin applies when it generates the timing (see [`CVT_H_GRANULARITY`]): the height must
|
||||
/// match exactly (CVT never touches the vertical active), the width may be up to one cell narrower
|
||||
/// than asked (never wider — that would be a different mode), and the refresh must land within 1 Hz
|
||||
/// of the request (which excludes the output's native 60 Hz entry for every rate we install a custom
|
||||
/// mode for). Widest wins, then fastest — so an exact-width mode always beats an aligned one, and a
|
||||
/// list carrying duplicate custom modes from earlier sessions still resolves.
|
||||
fn pick_custom_mode<'a>(
|
||||
modes: &'a [KModeRow],
|
||||
width: u32,
|
||||
height: u32,
|
||||
hz: u32,
|
||||
) -> Option<&'a KModeRow> {
|
||||
modes
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.h == height
|
||||
&& m.w <= width
|
||||
&& width - m.w < CVT_H_GRANULARITY
|
||||
&& (m.hz - f64::from(hz)).abs() < 1.0
|
||||
})
|
||||
.max_by(|a, b| {
|
||||
a.w.cmp(&b.w)
|
||||
.then(a.hz.partial_cmp(&b.hz).unwrap_or(std::cmp::Ordering::Equal))
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created virtual
|
||||
/// output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or name, see
|
||||
/// [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode** and return
|
||||
/// it as `(width, height, refresh_hz)`. `None` if the read-back failed entirely.
|
||||
///
|
||||
/// The apply command can report success yet leave the output on its old mode (rejected), and a
|
||||
/// silent size/rate mismatch surfaces downstream as a starved capture gate or judder — so the
|
||||
/// caller drives the pipeline off the *achieved* mode, not the requested one. The mode is selected
|
||||
/// by kscreen **mode id** resolved from the output's own list, never by the requested `WxH@Hz`
|
||||
/// string, because KWin's CVT generator may hand back a slightly different one
|
||||
/// ([`CVT_H_GRANULARITY`]).
|
||||
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> Option<(u32, u32, u32)> {
|
||||
let output = output.to_string();
|
||||
let mhz = hz.saturating_mul(1000);
|
||||
let run = |arg: String| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.arg(arg)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
let run = |arg: String| kscreen_ok(&[arg]);
|
||||
// Install the mode only if the output doesn't already carry a usable one: kscreen-doctor
|
||||
// APPENDS to the output's custom-mode list and KWin PERSISTS that list per output name
|
||||
// (`kwinoutputconfig.json`, which is why the same per-slot name is reused across sessions) — so
|
||||
// re-adding on every connect would grow the user's display list without bound.
|
||||
let mut modes = output_modes(&output);
|
||||
if pick_custom_mode(&modes, width, height, hz).is_none() {
|
||||
let _ = run(format!(
|
||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||
));
|
||||
modes = output_modes(&output);
|
||||
}
|
||||
let applied = match pick_custom_mode(&modes, width, height, hz) {
|
||||
Some(target) => {
|
||||
if (target.w, target.h) != (width, height) {
|
||||
tracing::info!(
|
||||
output,
|
||||
requested_w = width,
|
||||
requested_h = height,
|
||||
mode_w = target.w,
|
||||
mode_h = target.h,
|
||||
mode_hz = target.hz,
|
||||
"KWin aligned the custom mode to the CVT cell grain — streaming at its size"
|
||||
);
|
||||
}
|
||||
// By id first; the human `WxH@Hz` form (built from the mode's OWN size/refresh, not the
|
||||
// request) is the fallback for builds whose ids don't round-trip through the CLI.
|
||||
run(format!("output.{output}.mode.{}", target.id))
|
||||
|| run(format!(
|
||||
"output.{output}.mode.{}x{}@{}",
|
||||
target.w,
|
||||
target.h,
|
||||
target.hz.round() as u32
|
||||
))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
output,
|
||||
requested_w = width,
|
||||
requested_h = height,
|
||||
requested_hz = hz,
|
||||
offered = ?modes,
|
||||
"KWin offers no mode matching the request after addCustomMode — is kscreen-doctor \
|
||||
up to date, and KWin ≥ 6.6 (custom modes on virtual outputs)?"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
// Add the custom mode (a fresh output has none), then select it.
|
||||
let _ = run(format!(
|
||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||
));
|
||||
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
|
||||
match read_active_mode(&output) {
|
||||
Some((w, h, achieved)) => {
|
||||
let size_applied = (w, h) == (width, height);
|
||||
if achieved >= hz && size_applied {
|
||||
if achieved >= hz && (w, h) == (width, height) {
|
||||
tracing::info!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
"KWin virtual output: custom refresh applied"
|
||||
);
|
||||
} else if achieved >= hz {
|
||||
tracing::info!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
active_w = w,
|
||||
active_h = h,
|
||||
"KWin virtual output: custom refresh applied at a CVT-aligned size"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
output,
|
||||
@@ -461,7 +711,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
|
||||
achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)"
|
||||
);
|
||||
}
|
||||
(achieved.max(1), size_applied)
|
||||
Some((w, h, achieved.max(1)))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
@@ -471,7 +721,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
|
||||
"could not read back KWin virtual output refresh — assuming 60 Hz (is \
|
||||
kscreen-doctor installed?)"
|
||||
);
|
||||
(60, false)
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,14 +792,11 @@ fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
||||
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
||||
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_mode`]).
|
||||
fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||
let out = match std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
let out = match kscreen_json_bytes() {
|
||||
Some(o) => o,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let doc: serde_json::Value = match serde_json::from_slice(&out.stdout) {
|
||||
let doc: serde_json::Value = match serde_json::from_slice(&out) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
@@ -578,13 +825,10 @@ fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
||||
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
||||
fn a_managed_output_is_primary() -> bool {
|
||||
let Ok(out) = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
else {
|
||||
let Some(out) = kscreen_json_bytes() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out) else {
|
||||
return false;
|
||||
};
|
||||
doc.get("outputs")
|
||||
@@ -609,13 +853,7 @@ fn a_managed_output_is_primary() -> bool {
|
||||
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
||||
/// showing only the wallpaper) rather than failing the session.
|
||||
fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
|
||||
let kscreen = |args: &[String]| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let kscreen = |args: &[String]| kscreen_ok(args);
|
||||
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
||||
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
||||
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
||||
@@ -647,11 +885,7 @@ fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
|
||||
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
||||
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
||||
fn apply_virtual_primary_only(ours: &str) {
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{ours}.primary"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let ok = kscreen_ok(&[format!("output.{ours}.primary")]);
|
||||
if ok {
|
||||
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||
} else {
|
||||
@@ -661,8 +895,9 @@ fn apply_virtual_primary_only(ours: &str) {
|
||||
|
||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
|
||||
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
|
||||
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
|
||||
/// longer bound here — it moved to the registry's display group (§6.1, restored in-process via
|
||||
/// [`crate::kwin_output_mgmt::reenable_outputs`], `kscreen-doctor` fallback), which runs it once when
|
||||
/// the group's last member drops, BEFORE this keepalive is dropped.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
@@ -884,7 +1119,88 @@ fn run(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MANAGED_PREFIX;
|
||||
use super::{modes_from_json, pick_custom_mode, KModeRow, MANAGED_PREFIX};
|
||||
|
||||
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
|
||||
KModeRow {
|
||||
id: id.to_string(),
|
||||
w,
|
||||
h,
|
||||
hz,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reported regression: an iPhone 16 Pro Max asks for 2868x1320@120; libxcvt rounds the
|
||||
/// width down to the 8-pixel cell grain and the clock step lands 119.92, so KWin's list holds
|
||||
/// 2864x1320@119.92. Selecting by the REQUESTED `2868x1320@120` string matched nothing — the
|
||||
/// output stayed on its birth mode and the session fell back to 60 Hz. The picker must find it.
|
||||
#[test]
|
||||
fn picks_the_cvt_aligned_mode() {
|
||||
let modes = [
|
||||
row("1", 2868, 1320, 60.0), // the virtual output's native/birth mode
|
||||
row("2", 2864, 1320, 119.92), // the custom mode KWin actually generated
|
||||
];
|
||||
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("CVT-aligned mode");
|
||||
assert_eq!((got.id.as_str(), got.w, got.h), ("2", 2864, 1320));
|
||||
}
|
||||
|
||||
/// A width already on the cell grain (every PC resolution: 1920/2560/3840) round-trips exactly,
|
||||
/// and an exact-width mode outranks an aligned one when both are offered.
|
||||
#[test]
|
||||
fn exact_width_outranks_an_aligned_one() {
|
||||
let modes = [
|
||||
row("1", 2560, 1440, 60.0),
|
||||
row("2", 2552, 1440, 119.93), // a stale narrower custom mode from an earlier session
|
||||
row("3", 2560, 1440, 119.98),
|
||||
];
|
||||
let got = pick_custom_mode(&modes, 2560, 1440, 120).expect("exact mode");
|
||||
assert_eq!(got.id, "3");
|
||||
}
|
||||
|
||||
/// The picker must never wander onto an unrelated mode: not the 60 Hz native entry (the old
|
||||
/// fallback the reporter got stuck on), not a different height, not a wider width, and not a
|
||||
/// mode more than one cell narrower than asked.
|
||||
#[test]
|
||||
fn rejects_modes_that_are_not_the_request() {
|
||||
let modes = [
|
||||
row("1", 2868, 1320, 60.0), // native — refresh too far off
|
||||
row("2", 2868, 1080, 119.92), // wrong height
|
||||
row("3", 2880, 1320, 119.92), // wider than requested
|
||||
row("4", 2856, 1320, 119.92), // two cells narrower — not a CVT alignment of 2868
|
||||
row("5", 1920, 1080, 120.0), // unrelated
|
||||
];
|
||||
assert!(pick_custom_mode(&modes, 2868, 1320, 120).is_none());
|
||||
}
|
||||
|
||||
/// Mode + output ids come through as JSON strings on some KWin versions and numbers on others;
|
||||
/// both must parse, and a mode row missing its size/refresh is skipped rather than poisoning
|
||||
/// the list.
|
||||
#[test]
|
||||
fn parses_both_id_encodings() {
|
||||
let doc: serde_json::Value = serde_json::from_str(
|
||||
r#"{"outputs":[
|
||||
{"id":7,"name":"Virtual-punktfunk","modes":[
|
||||
{"id":"m1","size":{"width":2868,"height":1320},"refreshRate":60.0},
|
||||
{"id":42,"size":{"width":2864,"height":1320},"refreshRate":119.92},
|
||||
{"id":"broken","size":{"width":800}}
|
||||
]},
|
||||
{"id":1,"name":"eDP-1","modes":[
|
||||
{"id":"x","size":{"width":2864,"height":1320},"refreshRate":119.92}
|
||||
]}
|
||||
]}"#,
|
||||
)
|
||||
.expect("fixture parses");
|
||||
// Addressable by numeric id (how `resolve_kscreen_addr` returns it) and by name.
|
||||
for addr in ["7", "Virtual-punktfunk"] {
|
||||
let modes = modes_from_json(&doc, addr);
|
||||
assert_eq!(modes.len(), 2, "the malformed row is dropped ({addr})");
|
||||
assert_eq!(modes[1].id, "42", "numeric mode ids stringify ({addr})");
|
||||
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("aligned mode");
|
||||
assert_eq!(got.id, "42");
|
||||
}
|
||||
// Never reads another output's list (the eDP-1 entry carries a matching mode).
|
||||
assert!(modes_from_json(&doc, "Virtual-nope").is_empty());
|
||||
}
|
||||
|
||||
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
||||
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
//! In-process KDE output management (`kde_output_management_v2` + `kde_output_device_v2`).
|
||||
//!
|
||||
//! Topology — make the streamed output primary, disable the physical/bootstrap outputs, capture
|
||||
//! their modes for restore, re-enable them on teardown, position the output — used to shell out to
|
||||
//! `kscreen-doctor` (see [`super::kwin`]). But `kscreen-doctor` drives a *separate* stack:
|
||||
//! libkscreen picks a backend and, depending on the setup, waits on the kscreen KDED module over
|
||||
//! D-Bus. On a machine where THAT layer is wedged it blocks in its own connect and never returns —
|
||||
//! a field report (Nobara, KWin 6.6.4) showed every topology `kscreen-doctor` timing out at its 5 s
|
||||
//! budget, so the streamed output never became the desktop (`also_disabled=[]`) and bring-up took
|
||||
//! ~26 s.
|
||||
//!
|
||||
//! The compositor's OWN Wayland is provably responsive on that same session — the host just created
|
||||
//! a virtual output over it via `zkde_screencast` — so we drive `kde_output_management_v2` directly
|
||||
//! over Wayland here, sidestepping whatever wedges the standalone tool. Every wait is time-bounded,
|
||||
//! so a genuinely wedged compositor degrades to `handled = false` and the caller falls back to the
|
||||
//! `kscreen-doctor` path rather than hanging.
|
||||
//!
|
||||
//! KWin advertises one `kde_output_device_v2` global per output (the classic model; verified live:
|
||||
//! `kde_output_management_v2` v19, `kde_output_device_v2` v20 on KWin 6.6.4). We bind them all, read
|
||||
//! each output's name / enabled / priority / current-mode size, then build a
|
||||
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::backend::ObjectId;
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Generate the client bindings for the two vendored KDE protocols inline (no build.rs). The
|
||||
// management protocol references the device interfaces, so they can't share one `__interfaces`
|
||||
// module (each `generate_interfaces!` emits its own helper items, which collide). Instead — the
|
||||
// interdependent-protocol pattern from the `wayland-protocols` crate — `device` is a self-contained
|
||||
// module and `management` pulls in `device`'s interface statics + generated proxy types before its
|
||||
// own codegen, so its cross-protocol object args (`kde_output_device_v2`, `…_mode_v2`) resolve.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod device {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/kde-output-device-v2.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/kde-output-device-v2.xml");
|
||||
}
|
||||
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod management {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use super::super::device::__interfaces::*;
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/kde-output-management-v2.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
// The device protocol's generated modules/types, so the foreign object args resolve.
|
||||
use super::device::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/kde-output-management-v2.xml");
|
||||
}
|
||||
|
||||
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
|
||||
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
|
||||
use management::kde_mode_list_v2::KdeModeListV2 as ModeList;
|
||||
use management::kde_output_configuration_v2::{
|
||||
Event as ConfigEvent, KdeOutputConfigurationV2 as OutputConfig,
|
||||
};
|
||||
use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagement;
|
||||
|
||||
/// Highest interface versions we drive; we bind `min(advertised, MAX)`. Every request we issue is
|
||||
/// `since ≤ 2` (`create_configuration`/`enable`/`mode`/`position`/`apply` are v1, `set_primary_output`
|
||||
/// is v2) and every event we read is `since ≤ 18` (`priority`), so binding high and calling low is
|
||||
/// always in range on any KWin that advertises the globals.
|
||||
const MGMT_MAX: u32 = 22;
|
||||
const DEVICE_MAX: u32 = 24;
|
||||
|
||||
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
|
||||
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
|
||||
const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
|
||||
|
||||
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
|
||||
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
|
||||
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
|
||||
const POLL_MS: i32 = 100;
|
||||
|
||||
/// KWin's CVT generator aligns a custom mode's width DOWN to a multiple of this (libxcvt's cell
|
||||
/// grain), so the mode it builds for a `set_custom_modes` request may be a few px narrower than
|
||||
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
|
||||
const CVT_H_GRANULARITY: u32 = 8;
|
||||
|
||||
/// Which topology to apply once our output is resolved.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TopologyKind {
|
||||
/// Make ours the sole desktop: primary + disable every other enabled output.
|
||||
Exclusive,
|
||||
/// Make ours primary but leave the other outputs enabled.
|
||||
Primary,
|
||||
}
|
||||
|
||||
/// Outcome of [`apply_topology`].
|
||||
pub(crate) struct TopologyOutcome {
|
||||
/// UUID of our resolved virtual output — a stable per-output id that survives a mode-switch
|
||||
/// supersede (unlike the shared name) — for later [`set_position`] / restore addressing.
|
||||
pub our_uuid: Option<String>,
|
||||
/// The outputs we disabled, each `(name, "WxH@Hz")`, so teardown can restore them at their exact
|
||||
/// mode. Empty for `Primary`, or when nothing else was enabled.
|
||||
pub disabled: Vec<(String, String)>,
|
||||
/// `true` if the in-process path bound management, resolved our output, and applied (or tried to)
|
||||
/// a configuration. `false` ⇒ the compositor didn't answer in budget or our output never
|
||||
/// appeared, so the caller should fall back to `kscreen-doctor`.
|
||||
pub handled: bool,
|
||||
}
|
||||
|
||||
/// One output as read from `kde_output_device_v2`.
|
||||
#[derive(Default, Clone)]
|
||||
struct DeviceState {
|
||||
/// The global `name` number (higher = more recently advertised) — used to pick the newest of two
|
||||
/// same-named outputs during a supersede.
|
||||
global: u32,
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
enabled: bool,
|
||||
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||
priority: Option<u32>,
|
||||
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||
current_mode: Option<ObjectId>,
|
||||
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
|
||||
/// can pick the one matching a captured `WxH@Hz`.
|
||||
modes: Vec<(ObjectId, DeviceMode)>,
|
||||
/// Set once this output's `done` burst has been seen (its state is coherent to read).
|
||||
seen_done: bool,
|
||||
proxy: Option<OutputDevice>,
|
||||
}
|
||||
|
||||
/// Everything the enumerate/apply queue accumulates on one connection.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
management: Option<OutputManagement>,
|
||||
mgmt_name_version: Option<(u32, u32)>,
|
||||
devices: HashMap<ObjectId, DeviceState>,
|
||||
/// mode object id → `(width, height, refresh_mHz)`.
|
||||
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
|
||||
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||
sync_done: u32,
|
||||
/// Configuration apply verdict: `Some(true)` = applied, `Some(false)` = failed.
|
||||
applied: Option<bool>,
|
||||
failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => {
|
||||
if interface == OutputManagement::interface().name {
|
||||
let v = version.min(MGMT_MAX);
|
||||
state.management =
|
||||
Some(registry.bind::<OutputManagement, _, _>(name, v, qh, ()));
|
||||
state.mgmt_name_version = Some((name, v));
|
||||
} else if interface == OutputDevice::interface().name {
|
||||
let v = version.min(DEVICE_MAX);
|
||||
// The device's `name` global carries into the device's UserData so the event
|
||||
// handler can record it (newest-wins tie-break during a supersede).
|
||||
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
|
||||
let id = dev.id();
|
||||
state.devices.entry(id).or_default().proxy = Some(dev);
|
||||
}
|
||||
}
|
||||
wl_registry::Event::GlobalRemove { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Management has no events.
|
||||
impl Dispatch<OutputManagement, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &OutputManagement,
|
||||
_: management::kde_output_management_v2::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
// A client-built custom-mode list has no events; it just needs a Dispatch impl to be created.
|
||||
impl Dispatch<ModeList, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ModeList,
|
||||
_: management::kde_mode_list_v2::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's UserData is its global `name` number.
|
||||
impl Dispatch<OutputDevice, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
device: &OutputDevice,
|
||||
event: DeviceEvent,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let entry = state.devices.entry(device.id()).or_default();
|
||||
entry.global = *global;
|
||||
if entry.proxy.is_none() {
|
||||
entry.proxy = Some(device.clone());
|
||||
}
|
||||
match event {
|
||||
DeviceEvent::Name { name } => entry.name = Some(name),
|
||||
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
|
||||
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
|
||||
DeviceEvent::Done => entry.seen_done = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// The `mode` event hands us a server-created `kde_output_device_mode_v2`. The opcode is a bare
|
||||
// literal (the macro's fragment matcher rejects a `const` in some wayland-client versions); it is
|
||||
// pinned to `DEVICE_MODE_EVENT_OPCODE` by `mode_event_opcode_is_two` below.
|
||||
event_created_child!(State, OutputDevice, [
|
||||
2 => (DeviceMode, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<DeviceMode, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
mode: &DeviceMode,
|
||||
event: ModeEvent,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
|
||||
match event {
|
||||
ModeEvent::Size { width, height } => {
|
||||
entry.0 = width.max(0) as u32;
|
||||
entry.1 = height.max(0) as u32;
|
||||
}
|
||||
ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<OutputConfig, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &OutputConfig,
|
||||
event: ConfigEvent,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ConfigEvent::Applied => state.applied = Some(true),
|
||||
ConfigEvent::Failed => state.applied = Some(false),
|
||||
ConfigEvent::FailureReason { reason } => state.failure_reason = Some(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
serial: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
state.sync_done = state.sync_done.max(*serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected, bound output-management session on its own Wayland connection.
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
queue: wayland_client::EventQueue<State>,
|
||||
state: State,
|
||||
next_sync: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
|
||||
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
|
||||
/// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
|
||||
/// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
|
||||
fn open() -> Option<Session> {
|
||||
let conn = Connection::connect_to_env().ok()?;
|
||||
let queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut s = Session {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
next_sync: 0,
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Phase 1: process the registry globals (binds management + every device in the handler).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return None;
|
||||
}
|
||||
if s.state.management.is_none() {
|
||||
tracing::debug!(
|
||||
"KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
|
||||
fallback"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
|
||||
// (name / enabled / priority / current_mode / mode sizes / done).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return None;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||
/// Returns `true` on the barrier, `false` on timeout.
|
||||
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||
self.next_sync += 1;
|
||||
let serial = self.next_sync;
|
||||
let qh = self.queue.handle();
|
||||
let _cb = self.conn.display().sync(&qh, serial);
|
||||
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||
}
|
||||
|
||||
/// Bounded manual event loop: flush, dispatch what's queued, then poll the connection fd for up
|
||||
/// to `POLL_MS` and read. Mirrors the keepalive loop in `kwin.rs::run` (blocking_dispatch can't
|
||||
/// be interrupted, so we poll the fd instead). Returns `true` once `done(&state)` holds.
|
||||
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||
loop {
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||
return false;
|
||||
}
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
if self.conn.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(guard) = self.conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack and
|
||||
// the count `1` matches that single element, so `poll` reads `fd`/`events` and writes
|
||||
// `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because
|
||||
// `self.conn` (and the `prepare_read` guard) outlive the call. `poll` blocks up to
|
||||
// `timeout` ms and writes only `revents`; `pfd` is a fresh local that aliases nothing.
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh `kde_output_configuration_v2` on this connection.
|
||||
fn new_config(&self) -> OutputConfig {
|
||||
let qh = self.queue.handle();
|
||||
self.state
|
||||
.management
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.create_configuration(&qh, ())
|
||||
}
|
||||
|
||||
/// A fresh (empty) `kde_mode_list_v2` on this connection, for a `set_custom_modes` request.
|
||||
fn new_mode_list(&self) -> ModeList {
|
||||
let qh = self.queue.handle();
|
||||
self.state
|
||||
.management
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.create_mode_list(&qh, ())
|
||||
}
|
||||
|
||||
/// `apply()` the config and pump until `applied`/`failed` or the deadline. Returns the verdict
|
||||
/// (`true` applied, `false` failed/timeout).
|
||||
fn apply(&mut self, config: &OutputConfig, deadline: Instant) -> bool {
|
||||
self.state.applied = None;
|
||||
config.apply();
|
||||
let ok = self.pump_until(deadline, |st| st.applied.is_some());
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
matches!(self.state.applied, Some(true))
|
||||
}
|
||||
|
||||
/// The current-mode size of a device as `(w, h, refresh_mHz)`, if known.
|
||||
fn current_dims(&self, dev: &DeviceState) -> Option<(u32, u32, u32)> {
|
||||
let id = dev.current_mode.as_ref()?;
|
||||
self.state.mode_dims.get(id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
|
||||
/// `kscreen-doctor` restore path used, so teardown can put a panel back at its real refresh.
|
||||
fn mode_spec(dims: (u32, u32, u32)) -> String {
|
||||
let hz = ((dims.2 as f64) / 1000.0).round() as u32;
|
||||
format!("{}x{}@{}", dims.0, dims.1, hz)
|
||||
}
|
||||
|
||||
/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
|
||||
/// are `Virtual-punktfunk` / `Virtual-punktfunk-<id>`, so a same-family sibling session is never
|
||||
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
|
||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||
|
||||
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
|
||||
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
|
||||
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
|
||||
pub(crate) fn apply_topology(
|
||||
our_prefix: &str,
|
||||
our_w: u32,
|
||||
our_h: u32,
|
||||
kind: TopologyKind,
|
||||
) -> TopologyOutcome {
|
||||
let miss = || TopologyOutcome {
|
||||
our_uuid: None,
|
||||
disabled: Vec::new(),
|
||||
handled: false,
|
||||
};
|
||||
let Some(mut sess) = Session::open() else {
|
||||
return miss();
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
|
||||
// Resolve OUR output: managed-prefix name AND current size == the birth size (only the
|
||||
// just-created output sits there during a supersede); newest global wins the tie.
|
||||
let ours = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
|
||||
})
|
||||
.max_by_key(|d| d.global)
|
||||
.cloned();
|
||||
let Some(ours) = ours else {
|
||||
tracing::warn!(
|
||||
our_prefix,
|
||||
our_w,
|
||||
our_h,
|
||||
"KWin output management: our virtual output hasn't appeared yet — kscreen-doctor fallback"
|
||||
);
|
||||
return miss();
|
||||
};
|
||||
let our_uuid = ours.uuid.clone();
|
||||
let our_id = ours.proxy.as_ref().map(|p| p.id());
|
||||
|
||||
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
|
||||
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop.
|
||||
let sibling_is_primary = sess.state.devices.values().any(|d| {
|
||||
d.enabled
|
||||
&& d.priority == Some(1)
|
||||
&& d.proxy.as_ref().map(|p| p.id()) != our_id
|
||||
&& d.name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n.starts_with(MANAGED_PREFIX))
|
||||
});
|
||||
|
||||
// The physical/bootstrap outputs to disable for `Exclusive`: enabled, not any managed sibling,
|
||||
// not ours. Captured WITH their current mode so teardown restores the exact refresh.
|
||||
let mut to_disable: Vec<(OutputDevice, String, String)> = Vec::new();
|
||||
if kind == TopologyKind::Exclusive {
|
||||
for d in sess.state.devices.values() {
|
||||
let is_ours = d.proxy.as_ref().map(|p| p.id()) == our_id;
|
||||
let managed = d
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
|
||||
if d.enabled && !is_ours && !managed {
|
||||
if let (Some(name), Some(proxy)) = (d.name.clone(), d.proxy.clone()) {
|
||||
let spec = sess.current_dims(d).map(mode_spec).unwrap_or_default();
|
||||
to_disable.push((proxy, name, spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build one configuration: ensure ours is enabled, take primary (unless a sibling holds it),
|
||||
// disable the others. `apply()` is atomic — KWin re-homes the shell onto the remaining desktop.
|
||||
let config = sess.new_config();
|
||||
if let Some(proxy) = ours.proxy.as_ref() {
|
||||
config.enable(proxy, 1);
|
||||
if !sibling_is_primary {
|
||||
config.set_primary_output(proxy);
|
||||
}
|
||||
}
|
||||
for (proxy, _, _) in &to_disable {
|
||||
config.enable(proxy, 0);
|
||||
}
|
||||
let applied = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
|
||||
// Always report the outputs we ASKED to disable so teardown restores them: re-enabling an output
|
||||
// that never actually got disabled is a harmless no-op, whereas dropping them here would strand a
|
||||
// physical dark if KWin processed the disable but the `applied` ack didn't land in budget.
|
||||
let disabled: Vec<(String, String)> = to_disable
|
||||
.into_iter()
|
||||
.map(|(_, name, spec)| (name, spec))
|
||||
.collect();
|
||||
if applied {
|
||||
tracing::info!(
|
||||
also_disabled = ?disabled,
|
||||
primary_taken = !sibling_is_primary,
|
||||
"KWin output management: streamed output set as the desktop (in-process)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
reason = ?sess.state.failure_reason,
|
||||
also_disabled = ?disabled,
|
||||
"KWin output management: apply() not confirmed in budget — proceeding (restore will re-enable)"
|
||||
);
|
||||
}
|
||||
// We resolved our output and drove the config over Wayland; don't ALSO run kscreen-doctor — that
|
||||
// would double-apply (and on a wedged box it would just add 26 s of timeouts). `handled` is true
|
||||
// even on an unconfirmed apply; a genuinely absent management global / unresolved output took the
|
||||
// `handled = false` early returns above and falls back to kscreen-doctor.
|
||||
TopologyOutcome {
|
||||
our_uuid,
|
||||
disabled,
|
||||
handled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
|
||||
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
|
||||
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
|
||||
/// `addCustomMode` + `mode` shell-out (`set_custom_refresh`).
|
||||
///
|
||||
/// `set_custom_modes` hands KWin a one-entry mode list; KWin generates the CVT timing (so the width
|
||||
/// may align DOWN — see [`CVT_H_GRANULARITY`]) and adds the mode. We then SELECT it, which changes
|
||||
/// the output's size and triggers the screencast stream's renegotiation to the real refresh (the
|
||||
/// sacrificial-birth mechanism in `kwin::create`). Returns the ACTIVE mode read back after selection
|
||||
/// (Hz rounded), or `None` if management is absent, the output/generated mode never appeared, or an
|
||||
/// apply didn't confirm — the caller then falls back to `kscreen-doctor`. `set_custom_modes` REPLACES
|
||||
/// the custom list (idempotent across reconnects — no per-connect list growth), and it is `since 18`,
|
||||
/// so pre-6.6 KWin without it simply takes the `None` → kscreen-doctor path.
|
||||
pub(crate) fn set_custom_mode(
|
||||
our_prefix: &str,
|
||||
birth_w: u32,
|
||||
birth_h: u32,
|
||||
want_w: u32,
|
||||
want_h: u32,
|
||||
want_hz: u32,
|
||||
) -> Option<(u32, u32, u32)> {
|
||||
let mut sess = Session::open()?;
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
|
||||
// `set_custom_modes` is `since 18`; calling it on an older bound management object is a protocol
|
||||
// error, so bail to the kscreen-doctor fallback there (pre-6.6 KWin). Our bound version is
|
||||
// `min(advertised, MGMT_MAX)`.
|
||||
if sess.state.mgmt_name_version.map(|(_, v)| v).unwrap_or(0) < 18 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Resolve our output at its birth size (newest global wins a supersede).
|
||||
let our_proxy = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((birth_w, birth_h))
|
||||
})
|
||||
.max_by_key(|d| d.global)
|
||||
.and_then(|d| d.proxy.clone())?;
|
||||
let our_key = our_proxy.id();
|
||||
|
||||
let want_mhz = want_hz.saturating_mul(1000);
|
||||
// A generated mode IS our custom one iff: exact height, width at/just-below the request (a CVT
|
||||
// alignment), and refresh within 1 Hz — which excludes the sacrificial 60 Hz birth mode.
|
||||
let mode_matches = move |st: &State, mid: &ObjectId| -> bool {
|
||||
st.mode_dims.get(mid).is_some_and(|&(w, h, mhz)| {
|
||||
h == want_h
|
||||
&& w <= want_w
|
||||
&& want_w - w < CVT_H_GRANULARITY
|
||||
&& (mhz as i64 - want_mhz as i64).abs() <= 1000
|
||||
})
|
||||
};
|
||||
|
||||
// Build a one-entry custom-mode list (full blanking, like kscreen-doctor's `.full`) and install it.
|
||||
let mode_list = sess.new_mode_list();
|
||||
mode_list.set_resolution(want_w, want_h);
|
||||
mode_list.set_refresh_rate(want_mhz);
|
||||
mode_list.set_reduced_blanking(0);
|
||||
mode_list.add_mode();
|
||||
let config = sess.new_config();
|
||||
config.set_custom_modes(&our_proxy, &mode_list);
|
||||
let installed = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
mode_list.destroy();
|
||||
if !installed {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Wait for KWin to generate the mode and advertise it on the output.
|
||||
let found = |st: &State| -> bool {
|
||||
st.devices
|
||||
.get(&our_key)
|
||||
.is_some_and(|d| d.modes.iter().any(|(mid, _)| mode_matches(st, mid)))
|
||||
};
|
||||
if !sess.pump_until(deadline, found) {
|
||||
tracing::warn!(
|
||||
want_w,
|
||||
want_h,
|
||||
want_hz,
|
||||
"KWin output management: generated custom mode never appeared — kscreen-doctor fallback"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Grab the generated mode's proxy, then select it (this is what changes the size).
|
||||
let mode_proxy = {
|
||||
let dev = sess.state.devices.get(&our_key)?;
|
||||
dev.modes
|
||||
.iter()
|
||||
.find(|(mid, _)| mode_matches(&sess.state, mid))
|
||||
.map(|(_, p)| p.clone())?
|
||||
};
|
||||
let config = sess.new_config();
|
||||
config.mode(&our_proxy, &mode_proxy);
|
||||
let selected = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !selected {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Read back the active mode after selection — the size that really landed paces the encoder.
|
||||
let want_dims = sess
|
||||
.state
|
||||
.mode_dims
|
||||
.get(&mode_proxy.id())
|
||||
.map(|&(w, h, _)| (w, h));
|
||||
let landed = |st: &State| -> bool {
|
||||
st.devices
|
||||
.get(&our_key)
|
||||
.and_then(|d| d.current_mode.as_ref())
|
||||
.and_then(|mid| st.mode_dims.get(mid))
|
||||
.map(|&(w, h, _)| (w, h))
|
||||
== want_dims
|
||||
};
|
||||
sess.pump_until(deadline, landed);
|
||||
let dev = sess.state.devices.get(&our_key)?;
|
||||
let (cw, ch, cmhz) = sess.current_dims(dev)?;
|
||||
let hz = ((cmhz as f64) / 1000.0).round() as u32;
|
||||
tracing::info!(
|
||||
want_w,
|
||||
want_h,
|
||||
want_hz,
|
||||
active_w = cw,
|
||||
active_h = ch,
|
||||
active_hz = hz,
|
||||
"KWin output management: custom mode installed + selected (in-process)"
|
||||
);
|
||||
Some((cw, ch, hz.max(1)))
|
||||
}
|
||||
|
||||
/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
|
||||
/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
|
||||
/// caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
|
||||
if outputs.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(mut sess) = Session::open() else {
|
||||
return false;
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let config = sess.new_config();
|
||||
for (name, spec) in outputs {
|
||||
// Find the device by name (physical names are stable across a session).
|
||||
let Some(dev) = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.find(|d| d.name.as_deref() == Some(name.as_str()))
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(proxy) = dev.proxy.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// Enable first — a bare enable always succeeds, so a physical is never left dark.
|
||||
config.enable(proxy, 1);
|
||||
// Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
|
||||
if let Some(mode) = find_mode(&sess, &dev, spec) {
|
||||
config.mode(proxy, &mode);
|
||||
}
|
||||
}
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if ok {
|
||||
tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
|
||||
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
|
||||
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
|
||||
let Some(mut sess) = Session::open() else {
|
||||
return false;
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let Some(dev) = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.find(|d| d.uuid.as_deref() == Some(uuid))
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(proxy) = dev.proxy.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let config = sess.new_config();
|
||||
config.position(proxy, x, y);
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if ok {
|
||||
tracing::info!(
|
||||
uuid,
|
||||
x,
|
||||
y,
|
||||
"KWin output management: placed output (in-process)"
|
||||
);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Find a device's advertised mode proxy matching a captured `"WxH@Hz"` spec (Hz rounded), for
|
||||
/// restore. `None` if the spec is empty or no mode matches (the caller then enables without a mode
|
||||
/// and lets KWin pick its preferred one).
|
||||
fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode> {
|
||||
if spec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (wh, hz) = spec.split_once('@')?;
|
||||
let (w, h) = wh.split_once('x')?;
|
||||
let (w, h, hz): (u32, u32, u32) = (w.parse().ok()?, h.parse().ok()?, hz.parse().ok()?);
|
||||
dev.modes.iter().find_map(|(id, proxy)| {
|
||||
let (mw, mh, mmhz) = sess.state.mode_dims.get(id).copied()?;
|
||||
let mhz = ((mmhz as f64) / 1000.0).round() as u32;
|
||||
((mw, mh, mhz) == (w, h, hz)).then(|| proxy.clone())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
|
||||
#[test]
|
||||
fn mode_spec_rounds_millihertz() {
|
||||
assert_eq!(mode_spec((2560, 1440, 59940)), "2560x1440@60");
|
||||
assert_eq!(mode_spec((1920, 1080, 60000)), "1920x1080@60");
|
||||
assert_eq!(mode_spec((3840, 2160, 119880)), "3840x2160@120");
|
||||
}
|
||||
|
||||
/// The vendored device XML must keep `mode` at the opcode the `event_created_child!` macro
|
||||
/// hardcodes — a reorder there would bind the child to the wrong event and desync mode sizes.
|
||||
#[test]
|
||||
fn mode_event_opcode_is_two() {
|
||||
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Time-bounded child-process helpers.
|
||||
//!
|
||||
//! Every compositor query this crate makes shells out to a helper (`kscreen-doctor`, `systemctl`,
|
||||
//! `pw-dump`, …), and most of them are *clients of the very thing being diagnosed*: `kscreen-doctor`
|
||||
//! is a Wayland client, so against a wedged KWin it blocks in its own connect and **never returns**.
|
||||
//! `Command::status()` / `Command::output()` have no timeout, so one hung helper pinned the calling
|
||||
//! thread forever — and on the host that thread is the session's stream thread, whose only way to
|
||||
//! end a session is to return. A stuck query therefore became a permanently stuck session.
|
||||
//!
|
||||
//! These wrappers bound the wait: poll for exit until the budget runs out, then kill the child and
|
||||
//! report [`std::io::ErrorKind::TimedOut`], so callers see a plain "the helper failed" error and
|
||||
//! take their existing failure path instead of hanging.
|
||||
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
use std::process::{Command, ExitStatus, Output};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Poll interval while waiting for a child to exit. Short enough that a fast helper (the normal
|
||||
/// case — `kscreen-doctor` answers in tens of ms) isn't measurably delayed.
|
||||
const POLL: Duration = Duration::from_millis(20);
|
||||
|
||||
/// Run `cmd` to completion, killing it if it outlives `budget`.
|
||||
///
|
||||
/// Stdout/stderr are left as the caller configured them (inherited by default), so this is for
|
||||
/// commands run for their exit status alone — see [`output_within`] when the output is read.
|
||||
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
|
||||
let mut child = cmd.spawn()?;
|
||||
let deadline = Instant::now() + budget;
|
||||
loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => return Ok(status),
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait(); // reap it — never leave a zombie behind
|
||||
return Err(timed_out(cmd, budget));
|
||||
}
|
||||
None => std::thread::sleep(POLL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` to completion and capture its stdout/stderr, killing it if it outlives `budget`.
|
||||
///
|
||||
/// The output is read only after the child has exited, so a helper that fills the pipe buffer and
|
||||
/// stalls is caught by the budget rather than deadlocking the reader (these helpers emit at most a
|
||||
/// few hundred KiB, well under any real pipe pressure).
|
||||
pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Output> {
|
||||
let mut child = cmd
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + budget;
|
||||
loop {
|
||||
match child.try_wait()? {
|
||||
// Exited: `wait_with_output` now only drains already-buffered pipes.
|
||||
Some(_) => return child.wait_with_output(),
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(timed_out(cmd, budget));
|
||||
}
|
||||
None => std::thread::sleep(POLL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timed_out(cmd: &Command, budget: Duration) -> Error {
|
||||
let program = cmd.get_program().to_string_lossy().to_string();
|
||||
tracing::warn!(
|
||||
program,
|
||||
budget_ms = budget.as_millis() as u64,
|
||||
"helper did not exit within its budget — killed it (a wedged compositor/session bus is the \
|
||||
usual cause); treating it as a failed query"
|
||||
);
|
||||
Error::new(
|
||||
ErrorKind::TimedOut,
|
||||
format!("`{program}` did not exit within {budget:?}"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A helper that never exits must be killed at the budget and reported as `TimedOut` — the
|
||||
/// whole point of the module (an unbounded `status()` here is what wedged a whole session).
|
||||
#[test]
|
||||
fn a_hung_child_is_killed_at_the_budget() {
|
||||
let started = Instant::now();
|
||||
let err = status_within(Command::new("sleep").arg("30"), Duration::from_millis(150))
|
||||
.expect_err("must time out");
|
||||
assert_eq!(err.kind(), ErrorKind::TimedOut);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"must return at its budget, not the child's lifetime (took {:?})",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// The normal path is unaffected: a quick command still yields its status and its output.
|
||||
#[test]
|
||||
fn a_quick_child_returns_normally() {
|
||||
let st = status_within(&mut Command::new("true"), Duration::from_secs(5)).expect("ran");
|
||||
assert!(st.success());
|
||||
|
||||
let out = output_within(
|
||||
Command::new("echo").arg("punktfunk"),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("ran");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,15 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Budget for one `systemctl --user` / `dbus-update-activation-environment` call.
|
||||
///
|
||||
/// These talk to the session bus, and a bus that is itself restarting or wedged answers nothing —
|
||||
/// unbounded, that pinned the caller (on the host, the session's stream thread) forever. A restart
|
||||
/// of the portal units is the slowest legitimate case, hence the generous window; missing it just
|
||||
/// means the portal env settles late, which the callers already treat as best-effort.
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEMD_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||
@@ -86,9 +95,15 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
||||
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scrub_desktop_manager_env() {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
|
||||
.status();
|
||||
let _ = crate::proc::status_within(
|
||||
std::process::Command::new("systemctl").args([
|
||||
"--user",
|
||||
"unset-environment",
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
]),
|
||||
SYSTEMD_BUDGET,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -499,40 +514,46 @@ pub fn settle_desktop_portal(chosen: Compositor) {
|
||||
];
|
||||
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
||||
// re-activated portal/backend inherits the live session.
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "import-environment"])
|
||||
.args(VARS)
|
||||
.status();
|
||||
let _ = std::process::Command::new("dbus-update-activation-environment")
|
||||
.arg("--systemd")
|
||||
.args(VARS)
|
||||
.status();
|
||||
let _ = crate::proc::status_within(
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["--user", "import-environment"])
|
||||
.args(VARS),
|
||||
SYSTEMD_BUDGET,
|
||||
);
|
||||
let _ = crate::proc::status_within(
|
||||
std::process::Command::new("dbus-update-activation-environment")
|
||||
.arg("--systemd")
|
||||
.args(VARS),
|
||||
SYSTEMD_BUDGET,
|
||||
);
|
||||
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
||||
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
||||
// the now-live session, then let it settle before the injector reopens against it.
|
||||
if chosen == Compositor::Kwin {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args([
|
||||
let _ = crate::proc::status_within(
|
||||
std::process::Command::new("systemctl").args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-kde.service",
|
||||
"xdg-desktop-portal.service",
|
||||
])
|
||||
.status();
|
||||
]),
|
||||
SYSTEMD_BUDGET,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
}
|
||||
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
||||
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
||||
// re-read the now-live session, mirroring the KWin settling above.
|
||||
if chosen == Compositor::Hyprland {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args([
|
||||
let _ = crate::proc::status_within(
|
||||
std::process::Command::new("systemctl").args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-hyprland.service",
|
||||
"xdg-desktop-portal.service",
|
||||
])
|
||||
.status();
|
||||
]),
|
||||
SYSTEMD_BUDGET,
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
}
|
||||
tracing::info!(
|
||||
|
||||
@@ -31,5 +31,8 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
# console_session_mismatch: WTSGetActiveConsoleSessionId + ProcessIdToSessionId + GetCurrentProcessId.
|
||||
"Win32_System_RemoteDesktop",
|
||||
# input_desktop: OpenInputDesktop/SetThreadDesktop/GetUserObjectInformationW — display writes
|
||||
# follow the input desktop so a UAC/lock screen can't refuse them.
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Make display-config writes follow the INPUT desktop.
|
||||
//!
|
||||
//! Windows refuses `ChangeDisplaySettingsEx` / `SetDisplayConfig` issued from a thread that is not
|
||||
//! on the desktop currently receiving input. While a UAC consent prompt (or the lock / logon
|
||||
//! screen) is up, the input desktop is `Winlogon`; a host thread sitting on `WinSta0\Default` — the
|
||||
//! desktop the service explicitly launches it onto — then gets `DISP_CHANGE_FAILED` /
|
||||
//! `ERROR_ACCESS_DENIED` for EVERY write. A session starting in that window can never set its
|
||||
//! virtual display's mode, so the capturer sizes its ring to a stale mode, every composed frame is
|
||||
//! dropped for a size mismatch, and the client sits on a black screen until bring-up runs out of
|
||||
//! retries. Field-reported 2026-07-23: a consent prompt left up on an unattended host made the box
|
||||
//! unreachable until someone walked over to it.
|
||||
//!
|
||||
//! Measured on-glass that same day (RTX 4090 box, SYSTEM host in console session 2, a real
|
||||
//! interactive consent prompt up, virtual display `\\.\DISPLAY42` active):
|
||||
//!
|
||||
//! ```text
|
||||
//! INPUT desktop = Winlogon
|
||||
//! UNBOUND CDS_TEST -> -1 (DISP_CHANGE_FAILED) <- the field failure, reproduced
|
||||
//! UNBOUND SDC_VALIDATE -> 0x5 (ERROR_ACCESS_DENIED) <- the field failure, reproduced
|
||||
//! bound thread desktop -> Winlogon
|
||||
//! BOUND CDS_TEST -> 0 (DISP_CHANGE_SUCCESSFUL)
|
||||
//! BOUND SDC_VALIDATE -> 0x0 (ERROR_SUCCESS)
|
||||
//! ```
|
||||
//!
|
||||
//! The retry model mirrors `pf-inject`'s `sendinput.rs`: do NOT pay
|
||||
//! `OpenInputDesktop`/`SetThreadDesktop` on every write — issue the write, and only when it fails
|
||||
//! the way a wrong-desktop write fails do we rebind and retry once. That keeps the normal path
|
||||
//! byte-for-byte as it was (a working write is never touched) and makes this strictly additive.
|
||||
//!
|
||||
//! Unlike sendinput's injector — a dedicated thread that KEEPS its binding — these helpers run on
|
||||
//! shared/task threads, so the binding here is SCOPED: [`InputDesktopBinding`] restores the
|
||||
//! thread's original desktop on drop. A thread left bound to a `Winlogon` desktop that is later
|
||||
//! destroyed (prompt dismissed) would fail every subsequent display write for the life of the
|
||||
//! process — the exact wedge this module exists to remove, so it must not be introduced here.
|
||||
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, GetThreadDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
||||
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||
|
||||
/// `GENERIC_ALL` for the desktop open. The `windows` crate models desktop access as its own flag
|
||||
/// type and doesn't export the generic rights, so spell it out (same constant `sendinput.rs` and
|
||||
/// the cursor poller use).
|
||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||
|
||||
/// This thread's binding to the input desktop, restored on drop.
|
||||
///
|
||||
/// `GetThreadDesktop` returns a BORROWED handle (documented: it creates no new handle and must not
|
||||
/// be closed), so only the handle `OpenInputDesktop` produced is closed here — and only after the
|
||||
/// thread has been moved back off it.
|
||||
pub(crate) struct InputDesktopBinding {
|
||||
previous: HDESK,
|
||||
input: HDESK,
|
||||
/// `UOI_NAME` of the desktop bound to, for the "this write only landed because we rebound"
|
||||
/// log. Read once at bind time (the failure path only), never in steady state.
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl InputDesktopBinding {
|
||||
/// Bind the calling thread to the desktop currently receiving input. `None` when there is no
|
||||
/// reachable input desktop (not privileged for `Winlogon` — a host that is not SYSTEM) or the
|
||||
/// rebind is refused, in which case the caller keeps whatever result it already had rather than
|
||||
/// changing behaviour.
|
||||
pub(crate) fn bind() -> Option<Self> {
|
||||
// SAFETY: all four are FFI calls taking by-value args only. `GetThreadDesktop` yields a
|
||||
// borrowed handle for THIS thread (never closed here). `OpenInputDesktop` yields an owned
|
||||
// `HDESK` only on `Ok`; it is either installed by `SetThreadDesktop` (and then owned by the
|
||||
// returned guard, which closes it exactly once in `Drop`) or closed right here on failure —
|
||||
// so it is closed exactly once on every path and never used after close. `SetThreadDesktop`
|
||||
// rebinds only the calling thread, which owns no windows or hooks (these are display-config
|
||||
// worker threads), so it cannot fail on that account.
|
||||
unsafe {
|
||||
let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?;
|
||||
let input = OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||
)
|
||||
.ok()?;
|
||||
let name = desktop_name(input).unwrap_or_else(|| "<unnamed>".into());
|
||||
if SetThreadDesktop(input).is_err() {
|
||||
let _ = CloseDesktop(input);
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
previous,
|
||||
input,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputDesktopBinding {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.previous` is the borrowed desktop this thread started on and `self.input`
|
||||
// is the handle this guard uniquely owns. The thread is moved back FIRST, so the handle is
|
||||
// no longer the thread's desktop when it is closed — closed exactly once, never used after.
|
||||
unsafe {
|
||||
let _ = SetThreadDesktop(self.previous);
|
||||
let _ = CloseDesktop(self.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `UOI_NAME` of the current input desktop — `Some("Winlogon")` while a UAC consent prompt, the
|
||||
/// lock screen or the logon screen owns input, `Some("Default")` in normal operation, `None` when
|
||||
/// it cannot be opened at all.
|
||||
pub(crate) fn input_desktop_name() -> Option<String> {
|
||||
// SAFETY: `OpenInputDesktop` yields an owned handle only on `Ok`, which is closed exactly once
|
||||
// below and not used after.
|
||||
unsafe {
|
||||
let h = OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||
)
|
||||
.ok()?;
|
||||
let name = desktop_name(h);
|
||||
let _ = CloseDesktop(h);
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// `UOI_NAME` of an already-open desktop handle. Borrows `h` — closing it stays the caller's job.
|
||||
///
|
||||
/// # Safety
|
||||
/// `h` must be a live desktop handle for the duration of the call.
|
||||
unsafe fn desktop_name(h: HDESK) -> Option<String> {
|
||||
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
|
||||
let mut needed = 0u32;
|
||||
// SAFETY: `h` is live per this fn's contract; `name`/`needed` are live out-params and the call
|
||||
// writes at most `nlength` bytes, exactly the size passed.
|
||||
GetUserObjectInformationW(
|
||||
HANDLE(h.0),
|
||||
UOI_NAME,
|
||||
Some(name.as_mut_ptr().cast()),
|
||||
(name.len() * 2) as u32,
|
||||
Some(&mut needed),
|
||||
)
|
||||
.ok()?;
|
||||
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
||||
Some(String::from_utf16_lossy(&name[..len]))
|
||||
}
|
||||
|
||||
/// `true` when the input desktop is a SECURE one (`UOI_NAME` != `Default`: `Winlogon` for UAC
|
||||
/// consent / lock / logon, or a screen-saver desktop) — i.e. when display writes from the host's
|
||||
/// own desktop are being refused for that reason. `false` when it is normal OR unreadable: this
|
||||
/// only ever phrases a diagnostic, so an unknown desktop must not claim the secure one is up.
|
||||
pub(crate) fn input_desktop_is_secure() -> bool {
|
||||
input_desktop_name().is_some_and(|n| !n.eq_ignore_ascii_case("Default"))
|
||||
}
|
||||
|
||||
/// `ERROR_ACCESS_DENIED` — exactly how Windows refuses a `SetDisplayConfig` issued from a thread
|
||||
/// that is not on the input desktop (measured, see the module header). Narrower than "any error" on
|
||||
/// purpose: the CCD path also returns `ERROR_INVALID_PARAMETER` (0x57) for the unrelated
|
||||
/// exclusive-mode topology bug, and re-issuing THAT on another desktop would only confuse its
|
||||
/// diagnosis.
|
||||
const SDC_ACCESS_DENIED: i32 = 5;
|
||||
|
||||
/// [`retry_on_input_desktop`] specialised for the CCD writes, which all return a Win32 error code.
|
||||
pub(crate) fn retry_set_display_config(write: impl FnMut() -> i32) -> i32 {
|
||||
retry_on_input_desktop(|rc| *rc == SDC_ACCESS_DENIED, write)
|
||||
}
|
||||
|
||||
/// Run a display-config write; if it comes back the way a write from the wrong desktop comes back,
|
||||
/// bind this thread to the CURRENT input desktop and run it exactly once more.
|
||||
///
|
||||
/// `denied` decides which results are worth a retry — keep it NARROW (the specific
|
||||
/// wrong-desktop verdict), so a genuinely bad mode or a driver-level refusal is not re-issued and
|
||||
/// mis-attributed. The binding is dropped before returning, so the calling thread always leaves on
|
||||
/// the desktop it arrived on.
|
||||
pub(crate) fn retry_on_input_desktop<T>(
|
||||
denied: impl Fn(&T) -> bool,
|
||||
mut write: impl FnMut() -> T,
|
||||
) -> T {
|
||||
let first = write();
|
||||
if !denied(&first) {
|
||||
return first;
|
||||
}
|
||||
// Only worth the rebind when the input desktop is genuinely elsewhere; on a normal desktop the
|
||||
// refusal means something else entirely and a retry would just repeat it.
|
||||
let Some(binding) = InputDesktopBinding::bind() else {
|
||||
return first;
|
||||
};
|
||||
let second = write();
|
||||
if !denied(&second) {
|
||||
// Say so. A silent save is indistinguishable in a log from a write that never needed
|
||||
// saving, which makes "did the fix fire?" unanswerable in the field — and made the first
|
||||
// on-glass verification of this very change inconclusive.
|
||||
tracing::info!(
|
||||
desktop = %binding.name,
|
||||
"display write was refused off the input desktop — retried bound to it and it applied \
|
||||
(a UAC prompt / lock screen owns input; the session continues normally)"
|
||||
);
|
||||
}
|
||||
second
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod display_events;
|
||||
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||
#[cfg(target_os = "windows")]
|
||||
mod input_desktop;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod monitor_devnode;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -44,8 +44,8 @@ use windows::Win32::Devices::Display::{
|
||||
use windows::Win32::Foundation::POINTL;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||
DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT,
|
||||
DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||
};
|
||||
|
||||
use punktfunk_core::Mode;
|
||||
@@ -62,7 +62,9 @@ use punktfunk_core::Mode;
|
||||
pub unsafe fn force_extend_topology() {
|
||||
// A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset
|
||||
// for the currently-connected displays (the same code path DisplaySwitch.exe drives).
|
||||
let rc = SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!(
|
||||
"display topology forced to EXTEND (a new IddCx monitor would otherwise be CLONED onto the \
|
||||
@@ -152,11 +154,13 @@ pub unsafe fn activate_target_path(target_id: u32) -> bool {
|
||||
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
||||
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
||||
// skips this whole fallback ladder.
|
||||
let rc = SetDisplayConfig(
|
||||
Some(supplied.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
||||
);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(
|
||||
Some(supplied.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
@@ -273,14 +277,16 @@ pub unsafe fn force_mode_reenumeration() -> bool {
|
||||
let Some((paths, modes)) = query_active_config() else {
|
||||
return false;
|
||||
};
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
)
|
||||
});
|
||||
if rc != 0 {
|
||||
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
||||
}
|
||||
@@ -625,9 +631,15 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
||||
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
||||
// trailing args are null, and the API only reads its inputs.
|
||||
let test = unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||
};
|
||||
// A CDS write from a thread that is not on the input desktop is refused with DISP_CHANGE_FAILED
|
||||
// (UAC consent / lock screen up) — retry it bound to that desktop rather than declaring the mode
|
||||
// unsupported, which is what stranded sessions on a black screen for a whole bring-up.
|
||||
let test = crate::input_desktop::retry_on_input_desktop(
|
||||
|rc| *rc == DISP_CHANGE_FAILED,
|
||||
|| unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||
},
|
||||
);
|
||||
if test != DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::warn!(
|
||||
result = test.0,
|
||||
@@ -642,15 +654,20 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
||||
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
||||
// and the API only reads its inputs.
|
||||
let apply = unsafe {
|
||||
ChangeDisplaySettingsExW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
Some(&dm),
|
||||
None,
|
||||
CDS_UPDATEREGISTRY,
|
||||
None,
|
||||
)
|
||||
};
|
||||
// Same wrong-desktop retry as the validate above: the two calls bind independently, so an apply
|
||||
// still lands even when the secure desktop came up between them.
|
||||
let apply = crate::input_desktop::retry_on_input_desktop(
|
||||
|rc| *rc == DISP_CHANGE_FAILED,
|
||||
|| unsafe {
|
||||
ChangeDisplaySettingsExW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
Some(&dm),
|
||||
None,
|
||||
CDS_UPDATEREGISTRY,
|
||||
None,
|
||||
)
|
||||
},
|
||||
);
|
||||
if apply == DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::info!(
|
||||
"{gdi_name}: active mode set to {}x{}@{}",
|
||||
@@ -672,12 +689,20 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
|
||||
/// Human decode for a failed `ChangeDisplaySettingsExW` result. The two codes worth telling apart
|
||||
/// in a field log: `BADMODE` (the display's mode list genuinely lacks the mode) vs `FAILED` (the
|
||||
/// write itself was rejected — on a healthy driver that is the signature of a host process without
|
||||
/// console-session access, e.g. one trapped in a disconnected RDP session). An earlier revision
|
||||
/// printed "mode not advertised?" for BOTH, which sent a lid-closed field report chasing the wrong
|
||||
/// cause.
|
||||
/// write itself was rejected). An earlier revision printed "mode not advertised?" for BOTH, which
|
||||
/// sent a lid-closed field report chasing the wrong cause.
|
||||
///
|
||||
/// `FAILED` itself has two causes needing opposite fixes — no console-session access (disconnected
|
||||
/// RDP) versus the right session but the wrong desktop (UAC / lock / logon screen owns input) — so
|
||||
/// it asks which before naming one. See [`sdc_access_denied_hint`] for the same split on the CCD
|
||||
/// side.
|
||||
fn disp_change_reason(rc: i32) -> &'static str {
|
||||
match rc {
|
||||
-1 if crate::input_desktop::input_desktop_is_secure() => {
|
||||
"DISP_CHANGE_FAILED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||
prompt on the host"
|
||||
}
|
||||
-1 => {
|
||||
"DISP_CHANGE_FAILED: the display write was rejected — a host without console-session \
|
||||
access (disconnected RDP session / non-console session) fails ALL display writes \
|
||||
@@ -691,15 +716,28 @@ fn disp_change_reason(rc: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5) — per MS
|
||||
/// docs "the caller does not have access to the console session", the field signature of a host
|
||||
/// running in a disconnected RDP / non-console session. Every other rc gets no hint.
|
||||
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5). Every other
|
||||
/// rc gets no hint.
|
||||
///
|
||||
/// `ERROR_ACCESS_DENIED` has TWO field causes and they need opposite fixes, so ask which one before
|
||||
/// naming it. MS docs say only "the caller does not have access to the console session", which is
|
||||
/// the disconnected-RDP / non-console case — but the SAME rc comes back when the host IS in the
|
||||
/// console session and merely off the input desktop (UAC consent, lock or logon screen up). Naming
|
||||
/// only the first sent a 2026-07-23 investigation chasing a phantom RDP session while a consent
|
||||
/// prompt was the actual cause, on a host the message told to "run via the installed service" —
|
||||
/// which it already was. [`crate::input_desktop`] now retries these bound to the input desktop, so
|
||||
/// seeing this at all means even that did not help.
|
||||
fn sdc_access_denied_hint(rc: i32) -> &'static str {
|
||||
if rc == 5 {
|
||||
if rc != 5 {
|
||||
return "";
|
||||
}
|
||||
if crate::input_desktop::input_desktop_is_secure() {
|
||||
" (ERROR_ACCESS_DENIED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||
prompt on the host)"
|
||||
} else {
|
||||
" (ERROR_ACCESS_DENIED: the host has no console-session access — disconnected RDP \
|
||||
session? run via the installed service so it tracks the console session)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +939,7 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
||||
for attempt in 1..=4u32 {
|
||||
let (mut paths, modes) = query_active_config()?;
|
||||
let (mut paths, mut modes) = query_active_config()?;
|
||||
let mut others = 0u32;
|
||||
for p in paths.iter_mut() {
|
||||
if keep_target_ids.contains(&p.targetInfo.id) {
|
||||
@@ -923,17 +961,26 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
||||
others += 1;
|
||||
}
|
||||
}
|
||||
// The doomed display may have HELD the desktop origin (the physical is primary in the
|
||||
// single-slot exclusive case): re-anchor the kept sources so the supplied config still
|
||||
// contains a primary — an origin-less desktop is rejected 0x57 no matter which array
|
||||
// shape carries it (see `anchor_kept_sources_at_origin`).
|
||||
if others > 0 {
|
||||
anchor_kept_sources_at_origin(&paths, &mut modes);
|
||||
}
|
||||
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
||||
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
||||
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
||||
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
||||
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
||||
let rc = if others > 0 && attempt >= 2 {
|
||||
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD +
|
||||
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned —
|
||||
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never
|
||||
// converged; the same host applies the keep-only shape rc=0 whenever the topology
|
||||
// database has already made the virtual display sole. The final attempt also drops
|
||||
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
|
||||
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
|
||||
let keep_only = (others > 0 && attempt >= 2).then(|| {
|
||||
// ESCALATION (attempt 2+): supply ONLY the keep paths. Kept as belt-and-braces —
|
||||
// the field 0x57 this was built for turned out to be the missing desktop origin
|
||||
// (see `anchor_kept_sources_at_origin`), which rejected BOTH shapes identically;
|
||||
// but should some other validator still choke on the full array, the minimal
|
||||
// shape is the best last word. The final attempt also drops
|
||||
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
|
||||
// topology change — an actual path removal drives COMMIT_MODES on its own, so the
|
||||
// re-commit rationale doesn't need the flag here.
|
||||
@@ -946,17 +993,21 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
||||
"display isolate (CCD): escalating to a keep-only supplied config (attempt {attempt}/4, paths {}→{}, modes {}→{})",
|
||||
paths.len(), kp.len(), modes.len(), km.len()
|
||||
);
|
||||
SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), esc)
|
||||
} else {
|
||||
let mut flags = SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION;
|
||||
if others == 0 {
|
||||
flags |= SDC_SAVE_TO_DATABASE;
|
||||
(kp, km, esc)
|
||||
});
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| match &keep_only {
|
||||
Some((kp, km, esc)) => SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), *esc),
|
||||
None => {
|
||||
let mut flags = SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION;
|
||||
if others == 0 {
|
||||
flags |= SDC_SAVE_TO_DATABASE;
|
||||
}
|
||||
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
||||
}
|
||||
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
||||
};
|
||||
});
|
||||
// A failed apply must be VISIBLE even when the verification below passes vacuously (nothing
|
||||
// else was active to deactivate — the lid-closed laptop case, where the success INFO used to
|
||||
// swallow rc=0x5): the re-commit above is load-bearing (COMMIT_MODES → ASSIGN_SWAPCHAIN),
|
||||
@@ -979,7 +1030,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
||||
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
tracing::error!("display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (field-reported exclusive-mode bug)");
|
||||
// Name the survivors instead of assuming their kind — the field logs showed this path fire
|
||||
// with a sibling VIRTUAL display as the survivor (linger-expiry shrink), where the old
|
||||
// "a non-virtual display stayed active" wording sent the triage the wrong way.
|
||||
let survivors: Vec<String> = target_inventory()
|
||||
.iter()
|
||||
.filter(|t| t.active && !keep_target_ids.contains(&t.target_id))
|
||||
.map(|t| format!("{} {} \"{}\"", t.target_id, t.tech, t.friendly))
|
||||
.collect();
|
||||
tracing::error!(
|
||||
"display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — still active: [{}] (field-reported exclusive-mode bug)",
|
||||
survivors.join(", ")
|
||||
);
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
@@ -1040,6 +1102,60 @@ fn remap_mode_idx(
|
||||
})
|
||||
}
|
||||
|
||||
/// A committable desktop must still contain a PRIMARY — a source pinned exactly at the origin
|
||||
/// `(0,0)`. Deactivating the display that held the origin (the physical, in the exclusive
|
||||
/// topology) while the kept virtual stays pinned at its EXTEND offset supplies an origin-less
|
||||
/// desktop, and Windows rejects that wholesale with 0x57 ERROR_INVALID_PARAMETER no matter the
|
||||
/// array shape — the field box failed identically with the doomed path carried inactive AND with
|
||||
/// the keep-only escalation, yet the very same call converged rc=0 whenever a kept member already
|
||||
/// sat at `(0,0)`; the origin was the real variable all along. Translate the kept sources RIGIDLY
|
||||
/// (relative arrangement preserved) so the top-left-most lands exactly on the origin. A set that
|
||||
/// already covers `(0,0)` is left untouched, so a plain re-commit stays byte-identical and a
|
||||
/// user's negative-coordinate multi-monitor layout is never rearranged.
|
||||
unsafe fn anchor_kept_sources_at_origin(
|
||||
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||
modes: &mut [DISPLAYCONFIG_MODE_INFO],
|
||||
) {
|
||||
// Unique source-mode entries of the still-ACTIVE (kept) paths — clone-style pairs share one,
|
||||
// and a shared entry must be translated once.
|
||||
let mut idxs: Vec<usize> = Vec::new();
|
||||
for p in paths {
|
||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
|
||||
continue;
|
||||
}
|
||||
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
|
||||
let Some(m) = modes.get(idx) else { continue };
|
||||
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE && !idxs.contains(&idx) {
|
||||
idxs.push(idx);
|
||||
}
|
||||
}
|
||||
let positions: Vec<(i32, i32)> = idxs
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let pos = modes[i].Anonymous.sourceMode.position;
|
||||
(pos.x, pos.y)
|
||||
})
|
||||
.collect();
|
||||
if positions.contains(&(0, 0)) {
|
||||
return; // the kept set already holds the primary — don't touch a valid layout
|
||||
}
|
||||
// Lexicographic min over the actual positions — the anchor IS one of the kept sources, so
|
||||
// after translation one source sits exactly at (0,0), which is what the validator wants.
|
||||
let Some((ax, ay)) = positions.iter().copied().min() else {
|
||||
return; // no pinned kept sources — placement is the OS's (SDC_ALLOW_CHANGES), nothing to anchor
|
||||
};
|
||||
for &i in &idxs {
|
||||
let sm = &mut modes[i].Anonymous.sourceMode;
|
||||
sm.position.x -= ax;
|
||||
sm.position.y -= ay;
|
||||
}
|
||||
tracing::info!(
|
||||
"display isolate (CCD): kept source(s) re-anchored onto the desktop origin (primary) — the doomed display held (0,0) delta=({},{})",
|
||||
-ax,
|
||||
-ay
|
||||
);
|
||||
}
|
||||
|
||||
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
|
||||
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
|
||||
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
|
||||
@@ -1134,14 +1250,16 @@ pub unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) {
|
||||
if moved == 0 {
|
||||
return;
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!(
|
||||
?positions,
|
||||
@@ -1226,14 +1344,16 @@ pub unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig
|
||||
}
|
||||
}
|
||||
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
||||
} else {
|
||||
@@ -1253,11 +1373,13 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
);
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
@@ -1266,4 +1388,22 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
sdc_access_denied_hint(rc)
|
||||
);
|
||||
}
|
||||
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||
// removed) or even apply rc=0 yet re-light nothing (snapshotted while an earlier failed
|
||||
// teardown had the physicals off — the poisoned-snapshot chain from the field logs). Either
|
||||
// way, if no external physical panel is active after the apply while at least one is
|
||||
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
|
||||
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
|
||||
// lid must not be forced back on.
|
||||
let (connected, lit) = target_inventory()
|
||||
.iter()
|
||||
.filter(|t| t.external_physical)
|
||||
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
|
||||
if connected > 0 && lit == 0 {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
);
|
||||
force_extend_topology();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,7 @@ ash = "0.38"
|
||||
# (SCM_RIGHTS carries the fds; pixels never cross the socket).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Linux-only like the code under test: the render-node scan is exercised against a fixture tree.
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -34,6 +34,62 @@ const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444;
|
||||
mod gl;
|
||||
use gl::*;
|
||||
|
||||
/// PCI vendor id of the only GPU this importer can drive — everything below it ends in a CUDA
|
||||
/// context. Same constant the Vulkan bridge selects its physical device by.
|
||||
const PCI_VENDOR_NVIDIA: u32 = 0x10de;
|
||||
|
||||
/// The NVIDIA DRM render node: `PUNKTFUNK_ZEROCOPY_RENDER_NODE` > the first `/dev/dri/renderD*`
|
||||
/// whose sysfs PCI vendor is NVIDIA > `/dev/dri/renderD128`.
|
||||
///
|
||||
/// The fallback used to be the *whole* selection, which quietly assumed NVIDIA owns the first
|
||||
/// render node. On a hybrid laptop it does not — the iGPU is bound first, so `renderD128` is
|
||||
/// Intel, and this importer opened a Mesa GBM display, asked it for a pbuffer-capable OpenGL
|
||||
/// config (Mesa's GBM platform only ever advertises window-capable ones), got none, and reported
|
||||
/// "no EGL config for OpenGL" on a machine whose NVIDIA EGL stack was working perfectly. Pick the
|
||||
/// device by what it *is*.
|
||||
///
|
||||
/// Deliberately a local scan rather than a `pf_gpu` dependency: this crate is a leaf (the import
|
||||
/// worker is its own process, spawned for fault isolation), and `pf_gpu::linux_render_node` answers
|
||||
/// a different question anyway — it follows the operator's VAAPI/GPU *preference*, which may well
|
||||
/// name the iGPU. The answer needed here is not "which GPU should we use" but "where is CUDA".
|
||||
fn nvidia_render_node() -> std::path::PathBuf {
|
||||
use std::path::{Path, PathBuf};
|
||||
if let Some(p) = std::env::var_os("PUNKTFUNK_ZEROCOPY_RENDER_NODE").filter(|s| !s.is_empty()) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
// No NVIDIA node found — sysfs may simply not be mounted (a container without /sys). Keep the
|
||||
// historical guess; the CUDA context below is what actually fails if this host has no NVIDIA.
|
||||
nvidia_render_node_in(Path::new("/dev/dri"), Path::new("/sys/class/drm"))
|
||||
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||
}
|
||||
|
||||
/// The scan half of [`nvidia_render_node`], parameterized on its two roots so a test can pin the
|
||||
/// sysfs shape it depends on (`<sys_class_drm>/renderD129/device/vendor` holding `0x10de`). Nodes
|
||||
/// are visited in name order so the choice is stable across boots.
|
||||
fn nvidia_render_node_in(
|
||||
dri: &std::path::Path,
|
||||
sys_class_drm: &std::path::Path,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let mut nodes: Vec<std::ffi::OsString> = std::fs::read_dir(dri)
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name())
|
||||
.filter(|n| n.as_encoded_bytes().starts_with(b"renderD"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
nodes.sort();
|
||||
nodes
|
||||
.into_iter()
|
||||
.find(|node| {
|
||||
std::fs::read_to_string(sys_class_drm.join(node).join("device").join("vendor"))
|
||||
.ok()
|
||||
.and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok())
|
||||
== Some(PCI_VENDOR_NVIDIA)
|
||||
})
|
||||
.map(|node| dri.join(node))
|
||||
}
|
||||
|
||||
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
|
||||
struct GlBlit {
|
||||
program: u32,
|
||||
@@ -528,13 +584,15 @@ impl EglImporter {
|
||||
// GBM platform on the NVIDIA render node: this ties the EGLDisplay (and its GL contexts)
|
||||
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
||||
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
||||
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
||||
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
|
||||
let node = nvidia_render_node();
|
||||
let path = std::ffi::CString::new(node.as_os_str().as_encoded_bytes())
|
||||
.with_context(|| format!("render node path {} has an interior NUL", node.display()))?;
|
||||
// SAFETY: `path` is a live local `CString` (its constructor rejected interior NULs, so it
|
||||
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
|
||||
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
|
||||
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
|
||||
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
|
||||
ensure!(render_fd >= 0, "open {} for GBM", node.display());
|
||||
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
|
||||
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
|
||||
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
|
||||
@@ -546,7 +604,7 @@ impl EglImporter {
|
||||
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
|
||||
// the live `render_fd`, releasing it before the error return. No double-close.
|
||||
unsafe { libc::close(render_fd) };
|
||||
anyhow::bail!("gbm_create_device failed");
|
||||
anyhow::bail!("gbm_create_device failed on {}", node.display());
|
||||
}
|
||||
|
||||
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
|
||||
@@ -568,7 +626,7 @@ impl EglImporter {
|
||||
&[egl::ATTRIB_NONE],
|
||||
)
|
||||
}
|
||||
.context("eglGetPlatformDisplay(GBM) on the NVIDIA render node")?;
|
||||
.with_context(|| format!("eglGetPlatformDisplay(GBM) on {}", node.display()))?;
|
||||
egl.initialize(display).context("eglInitialize")?;
|
||||
|
||||
let exts = egl
|
||||
@@ -590,20 +648,46 @@ impl EglImporter {
|
||||
egl.bind_api(egl::OPENGL_API)
|
||||
.context("eglBindAPI(OpenGL)")?;
|
||||
// The default EGL_SURFACE_TYPE in eglChooseConfig is WINDOW_BIT, which a headless device
|
||||
// display has none of — request a pbuffer-capable config (we run surfaceless anyway).
|
||||
let config = egl
|
||||
.choose_first_config(
|
||||
display,
|
||||
&[
|
||||
egl::SURFACE_TYPE,
|
||||
egl::PBUFFER_BIT,
|
||||
egl::RENDERABLE_TYPE,
|
||||
egl::OPENGL_BIT,
|
||||
egl::NONE,
|
||||
],
|
||||
)
|
||||
// display has none of — ask for a pbuffer-capable config first (NVIDIA's GBM platform has
|
||||
// them, and this is the request every working host has been served for a year).
|
||||
//
|
||||
// Then fall back to no surface-type constraint at all, because a config's surface type is
|
||||
// irrelevant to us: we never create an EGLSurface, we `eglMakeCurrent` surfaceless. Mesa's
|
||||
// GBM platform advertises ONLY window-capable configs (gbm_surface is the whole point of
|
||||
// the platform there), so the pbuffer request finds nothing on any Mesa device — which is
|
||||
// how a hybrid host that landed on the iGPU reported "no EGL config for OpenGL" while its
|
||||
// NVIDIA EGL stack was fine. The node is picked correctly now; this keeps the failure from
|
||||
// being fatal (and unreadable) if some other driver makes the same choice Mesa did.
|
||||
let want_pbuffer = [
|
||||
egl::SURFACE_TYPE,
|
||||
egl::PBUFFER_BIT,
|
||||
egl::RENDERABLE_TYPE,
|
||||
egl::OPENGL_BIT,
|
||||
egl::NONE,
|
||||
];
|
||||
let any_surface = [egl::RENDERABLE_TYPE, egl::OPENGL_BIT, egl::NONE];
|
||||
let config = match egl
|
||||
.choose_first_config(display, &want_pbuffer)
|
||||
.context("eglChooseConfig")?
|
||||
.context("no EGL config for OpenGL")?;
|
||||
{
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
node = %node.display(),
|
||||
"no pbuffer-capable OpenGL EGL config — retrying without a surface-type \
|
||||
constraint (we run surfaceless)"
|
||||
);
|
||||
egl.choose_first_config(display, &any_surface)
|
||||
.context("eglChooseConfig (no surface-type constraint)")?
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no EGL config for OpenGL on {} — this display serves no \
|
||||
OpenGL-renderable config at all",
|
||||
node.display()
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let gl_ctx = egl
|
||||
.create_context(
|
||||
display,
|
||||
@@ -637,6 +721,7 @@ impl EglImporter {
|
||||
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
||||
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
||||
tracing::info!(
|
||||
node = %node.display(),
|
||||
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
||||
);
|
||||
Ok(EglImporter {
|
||||
@@ -1087,3 +1172,69 @@ impl Drop for EglImporter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A fake `/dev/dri` + `/sys/class/drm` pair: every `(node, vendor)` gets a device node file
|
||||
/// and, when `vendor` is `Some`, the sysfs `device/vendor` that identifies it.
|
||||
fn fixture(nodes: &[(&str, Option<&str>)]) -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dri = tmp.path().join("dev/dri");
|
||||
let sys = tmp.path().join("sys/class/drm");
|
||||
std::fs::create_dir_all(&dri).unwrap();
|
||||
for (node, vendor) in nodes {
|
||||
std::fs::write(dri.join(node), b"").unwrap();
|
||||
if let Some(v) = vendor {
|
||||
let dev = sys.join(node).join("device");
|
||||
std::fs::create_dir_all(&dev).unwrap();
|
||||
std::fs::write(dev.join("vendor"), v).unwrap();
|
||||
}
|
||||
}
|
||||
(tmp, dri, sys)
|
||||
}
|
||||
|
||||
/// The hybrid-laptop case this selection exists for: the iGPU is bound first, so the NVIDIA
|
||||
/// GPU is NOT renderD128 — which is what the old hardcoded path assumed.
|
||||
#[test]
|
||||
fn picks_the_nvidia_node_not_the_first_one() {
|
||||
let (_t, dri, sys) = fixture(&[
|
||||
("renderD128", Some("0x8086\n")),
|
||||
("renderD129", Some("0x10de\n")),
|
||||
]);
|
||||
assert_eq!(
|
||||
nvidia_render_node_in(&dri, &sys),
|
||||
Some(dri.join("renderD129"))
|
||||
);
|
||||
}
|
||||
|
||||
/// No NVIDIA GPU (or no readable sysfs) ⇒ no answer, and the caller keeps the historical
|
||||
/// `/dev/dri/renderD128` guess rather than inventing one.
|
||||
#[test]
|
||||
fn no_nvidia_node_yields_nothing() {
|
||||
let (_t, dri, sys) = fixture(&[("renderD128", Some("0x8086\n")), ("renderD129", None)]);
|
||||
assert_eq!(nvidia_render_node_in(&dri, &sys), None);
|
||||
// Missing roots entirely (a container without /sys, or without /dev/dri).
|
||||
assert_eq!(
|
||||
nvidia_render_node_in(Path::new("/nonexistent/dri"), &sys),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Ignore card/control nodes, and visit render nodes in name order so a two-NVIDIA host picks
|
||||
/// the same one every boot.
|
||||
#[test]
|
||||
fn scans_render_nodes_only_and_in_order() {
|
||||
let (_t, dri, sys) = fixture(&[
|
||||
("card0", Some("0x10de\n")),
|
||||
("renderD130", Some("0x10de\n")),
|
||||
("renderD129", Some("0x10de\n")),
|
||||
]);
|
||||
assert_eq!(
|
||||
nvidia_render_node_in(&dri, &sys),
|
||||
Some(dri.join("renderD129"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,48 @@ pub fn gpu_import_disabled() -> bool {
|
||||
GPU_IMPORT_DISABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The same idea for the OTHER zero-copy half: consecutive failures to import a raw dmabuf in the
|
||||
/// encoder (VAAPI's libva import, PyroWave's Vulkan one) with no successful frame in between.
|
||||
///
|
||||
/// That import can fail for reasons no retry can fix — a driver that will not take what the
|
||||
/// compositor allocates. The encode-stall recovery above it cannot tell the difference, so it
|
||||
/// rebuilt the identical failing encoder five times and then ended the video session, on every
|
||||
/// connection, forever: a hybrid Intel/NVIDIA laptop reporting `vaCreateSurfaces` →
|
||||
/// `VA_STATUS_ERROR_ALLOCATION_FAILED` on its first frame could not stream at all until its
|
||||
/// 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.
|
||||
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) {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
|
||||
/// [`note_raw_dmabuf_import_failure`]).
|
||||
pub fn raw_dmabuf_import_disabled() -> bool {
|
||||
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||
const fn fourcc(c: &[u8; 4]) -> u32 {
|
||||
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
||||
|
||||
@@ -1813,9 +1813,11 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out as *mut u8, cert.len());
|
||||
// `.cast()`, not `as *mut u8`: `c_char` is i8 on x86_64 but u8 on aarch64, so the
|
||||
// `as` form is a REQUIRED conversion on one and a no-op clippy rejects on the other.
|
||||
std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out.cast::<u8>(), cert.len());
|
||||
*cert_pem_out.add(cert.len()) = 0;
|
||||
std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out as *mut u8, key.len());
|
||||
std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out.cast::<u8>(), key.len());
|
||||
*key_pem_out.add(key.len()) = 0;
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
|
||||
//! (bufferbloat) — this is the early-warning signal loss-based control lacks;
|
||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||
//! evidence there is.
|
||||
//! evidence there is;
|
||||
//! - **host-encode-latency rise** — the host's per-AU 0xCF `encode_us` climbing above its rolling
|
||||
//! baseline: the ENCODER falling behind its frame budget (the compute knee), the one failure a
|
||||
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||||
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||||
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||||
//! encoder off a cliff the network could carry.
|
||||
//!
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
@@ -94,6 +100,25 @@ const UTILIZATION_DEN: u64 = 4;
|
||||
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
|
||||
const PROVEN_HEADROOM_NUM: u32 = 3;
|
||||
const PROVEN_HEADROOM_DEN: u32 = 2;
|
||||
/// How far the window's mean HOST-ENCODE latency (the 0xCF `HostStages::encode_us` the host
|
||||
/// already ships per AU) may rise above its rolling baseline before the window is bad. This is
|
||||
/// the down-driver for the ENCODER's compute knee — the failure loss/OWD/decode are all blind
|
||||
/// to: on a fat LAN the controller can climb to a rate the link carries fine but the ASIC
|
||||
/// can't encode inside the frame budget (4K120 HEVC at ~800 Mbps ≈ 9.3 ms against 8.33), and
|
||||
/// the only symptom is encode time. Baseline-RELATIVE on purpose: an escalated host reports
|
||||
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||||
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||||
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||||
const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
|
||||
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -121,6 +146,27 @@ pub(crate) struct BitrateController {
|
||||
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
||||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||||
decode_means: VecDeque<i64>,
|
||||
/// Recent window mean host-encode latencies (µs, from the 0xCF datagrams); rolling-min
|
||||
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||||
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||||
encode_means: VecDeque<i64>,
|
||||
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||||
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
|
||||
host_cap_kbps: Option<u32>,
|
||||
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||||
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||||
last_requested_kbps: Option<u32>,
|
||||
/// Consecutive short-ack streak: the value and how many times in a row it was acked. Two
|
||||
/// identical short acks latch [`host_cap_kbps`](Self::host_cap_kbps) — one can be a
|
||||
/// transient (a failed host rebuild keeping the old rate); the host's resolves are
|
||||
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||||
short_ack_kbps: u32,
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
cap_probe_windows: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -147,6 +193,12 @@ impl BitrateController {
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
host_cap_kbps: None,
|
||||
last_requested_kbps: None,
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
@@ -168,22 +220,68 @@ impl BitrateController {
|
||||
|
||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||
///
|
||||
/// A SHORT ack (below what we asked) is the host telling us about a limit the network
|
||||
/// signals can't see — its encoder's codec-level ceiling, or a climb refusal while encode
|
||||
/// can't hold cadence. Two consecutive short acks at the SAME value latch it as
|
||||
/// [`host_cap_kbps`](Self::host_cap_kbps), stopping the AIMD sawtooth from re-poking a
|
||||
/// limit the host already refused; ONE is not enough — a failed host rebuild also acks
|
||||
/// short once, and latching a transient would cap the session on a hiccup.
|
||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||
if kbps > 0 {
|
||||
if kbps < self.current_kbps {
|
||||
// Our own decrease changes the encode-time regime (less work per frame; on an
|
||||
// escalated host the queue offset shifts too) — judging the new regime against
|
||||
// the old baseline would train-fire the encode down-driver. Re-seed it.
|
||||
self.encode_means.clear();
|
||||
}
|
||||
if let Some(req) = self.last_requested_kbps.take() {
|
||||
if kbps < req {
|
||||
if self.short_ack_kbps == kbps {
|
||||
self.short_acks += 1;
|
||||
} else {
|
||||
self.short_ack_kbps = kbps;
|
||||
self.short_acks = 1;
|
||||
}
|
||||
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||||
tracing::info!(
|
||||
cap_kbps = kbps,
|
||||
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||||
refusal) — climbs stop here until it lifts"
|
||||
);
|
||||
self.host_cap_kbps = Some(kbps.max(self.floor_kbps));
|
||||
self.cap_probe_windows = 0;
|
||||
}
|
||||
} else {
|
||||
self.short_acks = 0;
|
||||
}
|
||||
}
|
||||
self.current_kbps = kbps;
|
||||
}
|
||||
self.unacked = 0;
|
||||
}
|
||||
|
||||
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||||
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||||
/// probe-measured `ceiling_kbps` (a LINK property) survives.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.encode_means.clear();
|
||||
}
|
||||
|
||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
||||
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
||||
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent), `actual_kbps`
|
||||
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline
|
||||
/// really carried, as opposed to the target it was allowed; feeds the utilization climb gate
|
||||
/// and the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in
|
||||
/// the window.
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent),
|
||||
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
|
||||
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
|
||||
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||||
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
|
||||
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
|
||||
/// window.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn on_window(
|
||||
&mut self,
|
||||
@@ -192,6 +290,7 @@ impl BitrateController {
|
||||
loss_ppm: u32,
|
||||
owd_mean_us: Option<i64>,
|
||||
decode_mean_us: Option<i64>,
|
||||
encode_mean_us: Option<i64>,
|
||||
actual_kbps: u32,
|
||||
flushed: bool,
|
||||
) -> Option<u32> {
|
||||
@@ -242,6 +341,23 @@ impl BitrateController {
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||||
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||||
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||||
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||||
let (encode_bad, encode_severe) = match encode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.encode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
|
||||
if self.encode_means.len() == BASELINE_WINDOWS {
|
||||
self.encode_means.pop_front();
|
||||
}
|
||||
self.encode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
||||
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
||||
@@ -253,8 +369,9 @@ impl BitrateController {
|
||||
// deep decode-latency excursion) or loss far past any blip — one window is enough.
|
||||
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
|
||||
// still needs two consecutive windows.
|
||||
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
|
||||
let severe =
|
||||
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -264,6 +381,29 @@ impl BitrateController {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
}
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
|
||||
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
|
||||
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
|
||||
// just re-latches from the next pair of short acks, at zero encoder cost.
|
||||
if let Some(cap) = self.host_cap_kbps {
|
||||
if bad {
|
||||
self.cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.cap_probe_windows += 1;
|
||||
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
self.cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
tracing::debug!(
|
||||
from_kbps = cap,
|
||||
to_kbps = lifted,
|
||||
"adaptive bitrate: re-probing above the learned host cap"
|
||||
);
|
||||
self.host_cap_kbps = Some(lifted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -284,10 +424,14 @@ impl BitrateController {
|
||||
// utilized window after a long-enough clean run climbs immediately.
|
||||
let utilized =
|
||||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||||
let cap = self
|
||||
// The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
|
||||
// the host's short acks measured the ENCODER — whichever binds first is the limit.
|
||||
let eff_ceiling = self
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < self.ceiling_kbps && utilized && cap > self.current_kbps {
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
// Slow start: double on every cooled clean window until the first congestion signal
|
||||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||
@@ -308,6 +452,7 @@ impl BitrateController {
|
||||
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
||||
self.last_change = Some(now);
|
||||
self.unacked += 1;
|
||||
self.last_requested_kbps = Some(kbps);
|
||||
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
|
||||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||||
Some(kbps)
|
||||
@@ -331,7 +476,16 @@ mod tests {
|
||||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||||
let mut out = None;
|
||||
for i in from..from + n {
|
||||
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
||||
out = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
if out.is_some() {
|
||||
return out;
|
||||
}
|
||||
@@ -345,7 +499,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
let now = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, 1_000_000, true),
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -356,22 +510,58 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
// The second consecutive bad window backs off ×0.7.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 1),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
); // bad #1 again
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 7),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -382,19 +572,28 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and so does a jump-to-live flush.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, 1_000_000, true),
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and ≥6 % window loss.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 80_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
80_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
@@ -404,18 +603,18 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||
// boundary (tick 2 = 1.5 s) it fires.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -426,17 +625,17 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(5_000)
|
||||
);
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -446,7 +645,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
@@ -469,9 +668,16 @@ mod tests {
|
||||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||
let mut got = Vec::new();
|
||||
for i in 0..14 {
|
||||
if let Some(k) =
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false)
|
||||
{
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
}
|
||||
@@ -485,20 +691,47 @@ mod tests {
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(40_000)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// Severe window → immediate ×0.7, and slow start is over.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 2),
|
||||
1,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(28_000)
|
||||
);
|
||||
c.on_ack(28_000);
|
||||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||
let mut next = None;
|
||||
for i in 3..12 {
|
||||
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
||||
next = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
if next.is_some() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
break;
|
||||
@@ -512,7 +745,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
c.set_ceiling(1_000_000);
|
||||
assert_eq!(
|
||||
c.on_window(Instant::now(), 0, 0, None, None, 1_000_000, false),
|
||||
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -527,17 +760,44 @@ mod tests {
|
||||
// Establish a ~10 ms baseline over a few clean windows.
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(50_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(50_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 5), 0, 0, Some(52_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 5),
|
||||
0,
|
||||
0,
|
||||
Some(52_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
@@ -557,6 +817,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -572,6 +833,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -584,6 +846,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -605,6 +868,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -620,6 +884,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -634,6 +899,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -657,6 +923,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false
|
||||
),
|
||||
@@ -673,6 +940,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
18_000,
|
||||
false
|
||||
),
|
||||
@@ -696,6 +964,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -710,6 +979,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
30_000,
|
||||
false
|
||||
),
|
||||
@@ -732,6 +1002,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -741,7 +1012,16 @@ mod tests {
|
||||
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
||||
for i in 2..30 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), Some(4_000), 600, false),
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(4_000),
|
||||
None,
|
||||
600,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -761,6 +1041,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -776,6 +1057,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -783,6 +1065,235 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_identical_short_acks_latch_the_host_cap() {
|
||||
// The 4K120 field failure: the encoder ceilings at ~794 Mbps while the link carries
|
||||
// more — the host acks short. TWO identical short acks teach the cap; climbs then stop
|
||||
// poking a limit the host already refused (the rebuild-storm driver).
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
// First short ack: current follows (authoritative), but one short ack is not a cap.
|
||||
c.on_ack(794_000);
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
// The next climb overshoots again and is short-acked at the SAME value: latch.
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
// Parked AT the learned cap, nothing left to climb to — no more requests.
|
||||
assert_eq!(run_clean(&mut c, start, 20, 12), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_short_ack_is_a_transient_not_a_cap() {
|
||||
// A failed host rebuild acks short once (it kept the old rate) — latching THAT would
|
||||
// cap the session on a driver hiccup. The streak must survive only identical repeats.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(400_000); // rebuild failed, host kept the old rate
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
// The retry applies fully: streak broken, still no cap, full authority kept.
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(800_000));
|
||||
c.on_ack(800_000);
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_clears_the_learned_cap() {
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
// 4K120's ceiling means nothing at the new mode — the cap must not survive the switch
|
||||
// (the probe-measured link ceiling does).
|
||||
c.on_mode_switch();
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 1_400_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
|
||||
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 20 + i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_encode_latency_rise_backs_off() {
|
||||
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||||
// (the 4K120 case: ~9.3 ms against an 8.33 ms budget shows up nowhere else). Two risen
|
||||
// windows → ×0.7, exactly like an OWD/decode rise.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(11_500),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_encode_excursion_is_severe() {
|
||||
// Encode time shooting ≈1.5 frame budgets over baseline = the queue is growing past
|
||||
// the knee right now — no two-window confirmation.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_decrease_rebases_the_encode_baseline() {
|
||||
// After OUR OWN decrease the encode regime legitimately changes (less work per frame;
|
||||
// an escalated host's reported encode_us also carries a queue offset) — the old
|
||||
// baseline must not train-fire repeated backoffs down to the floor.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
}
|
||||
let _ = c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_500),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
// The decrease applies → rebase. The new regime's ~15 ms means (an escalated host's
|
||||
// queue offset) would be far over the OLD 7 ms baseline, but must now read clean.
|
||||
c.on_ack(14_000);
|
||||
for i in 8..11 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(15_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -791,7 +1302,7 @@ mod tests {
|
||||
let mut i = 0;
|
||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||
while i < 60 {
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, 1_000_000, false)
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
|
||||
.is_some()
|
||||
{
|
||||
sent += 1;
|
||||
|
||||
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// Host encode-stage latency accumulator — [`DecodeLatAcc`]'s mirror for the HOST side of the
|
||||
/// pipeline. The datagram task adds one sample per 0xCF `HostStages::encode_us` (host encoder
|
||||
/// submit → bitstream ready) and the pump drains a window mean into
|
||||
/// [`crate::abr::BitrateController::on_window`]'s encode signal. Host encode time was measured,
|
||||
/// shipped and drawn on the overlay, but never an ABR input — which is how a fat-LAN Automatic
|
||||
/// session drove the encoder past its compute knee with nothing to stop it (§ABR overdrive).
|
||||
/// Its own accumulator rather than the overlay's `host_timing` channel: that channel is a lossy
|
||||
/// `try_send` the embedder may never drain, and the controller must not depend on it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EncodeLatAcc {
|
||||
pub(crate) sum_us: u64,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||
|
||||
@@ -36,6 +36,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
};
|
||||
let handshake::HandshakeOut {
|
||||
conn,
|
||||
ep,
|
||||
session,
|
||||
ctrl_send,
|
||||
ctrl_recv,
|
||||
@@ -111,6 +112,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
|
||||
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
|
||||
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
|
||||
// Bumped by the control task on every accepted mode switch (the `clock_gen` pattern): the
|
||||
// pump resets the controller's mode-scoped learned state (host cap, encode baseline).
|
||||
let mode_gen = Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
||||
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
||||
@@ -127,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_gen: clock_gen.clone(),
|
||||
clip_event_tx: clip_event_tx.clone(),
|
||||
cursor_shape_tx,
|
||||
mode_gen: mode_gen.clone(),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
@@ -140,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
cursor_state_tx,
|
||||
));
|
||||
|
||||
@@ -175,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
decode_lat,
|
||||
encode_lat,
|
||||
mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
@@ -192,4 +203,11 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
0
|
||||
};
|
||||
conn.close(close_code.into(), b"client closed");
|
||||
// Flush the CONNECTION_CLOSE before the runtime is dropped (the same discipline as the pairing
|
||||
// + probe paths). `close` only queues the frame — the endpoint driver puts it on the wire, and
|
||||
// this fn is the body of a `block_on` whose runtime is dropped the instant it returns, so
|
||||
// without this the driver could simply never be polled again. The host then saw a deliberate
|
||||
// quit as silence: no `QUIT_CLOSE_CODE`, an 8 s idle timeout, and the keep-alive linger meant
|
||||
// for an UNWANTED disconnect. Bounded — a host already gone must not delay the client's exit.
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), ep.wait_idle()).await;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
|
||||
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
||||
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
||||
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
|
||||
/// Bumped on every ACCEPTED mode switch (the `clock_gen` pattern): the pump watches it and
|
||||
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
|
||||
/// knee it was taught belong to the OLD mode.
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl ControlTask {
|
||||
@@ -40,6 +44,7 @@ impl ControlTask {
|
||||
clock_gen,
|
||||
clip_event_tx,
|
||||
cursor_shape_tx,
|
||||
mode_gen,
|
||||
} = self;
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
@@ -88,6 +93,7 @@ impl ControlTask {
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
if ack.accepted {
|
||||
*mode_slot.lock().unwrap() = ack.mode;
|
||||
mode_gen.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||
} else {
|
||||
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||
|
||||
@@ -19,6 +19,12 @@ pub(super) struct DataPump {
|
||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||
pub(super) clock_gen: Arc<AtomicU32>,
|
||||
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
/// Host encode-stage latency window accumulator (the ABR encode signal — see
|
||||
/// [`super::super::frame_channel::EncodeLatAcc`]); fed by the datagram task.
|
||||
pub(super) encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||
/// Accepted-mode-switch generation (control task bumps): a change resets the controller's
|
||||
/// mode-scoped learned state ([`BitrateController::on_mode_switch`]).
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
@@ -41,6 +47,8 @@ impl DataPump {
|
||||
clock_offset: pump_clock_offset,
|
||||
clock_gen: pump_clock_gen,
|
||||
decode_lat: pump_decode_lat,
|
||||
encode_lat: pump_encode_lat,
|
||||
mode_gen: pump_mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
@@ -127,6 +135,7 @@ impl DataPump {
|
||||
let mut clock_detector_armed = true;
|
||||
let mut resync_wanted = false;
|
||||
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
let mut seen_mode_gen = pump_mode_gen.load(Ordering::Relaxed);
|
||||
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
||||
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
||||
@@ -338,9 +347,15 @@ impl DataPump {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
||||
// feed the controller this window's congestion signals; a decision becomes a
|
||||
// SetBitrate on the control stream.
|
||||
// Adaptive bitrate: an accepted mode switch first (it invalidates the
|
||||
// mode-scoped learned state), then drain any host ack (its clamp is
|
||||
// authoritative), then feed the controller this window's congestion signals; a
|
||||
// decision becomes a SetBitrate on the control stream.
|
||||
let mg = pump_mode_gen.load(Ordering::Relaxed);
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
@@ -356,6 +371,14 @@ impl DataPump {
|
||||
*acc = DecodeLatAcc::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// Same drain for the host-encode window (0xCF `encode_us` via the datagram
|
||||
// task) — `None` on an old host that doesn't send stage timings.
|
||||
let encode_mean_us = {
|
||||
let mut acc = pump_encode_lat.lock().unwrap();
|
||||
let (sum, count) = (acc.sum_us, acc.count);
|
||||
*acc = Default::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
@@ -369,17 +392,19 @@ impl DataPump {
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
||||
// tell a decode-/encode-driven re-target (the new signals — elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
tracing::info!(
|
||||
kbps,
|
||||
loss_ppm,
|
||||
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||
encode_mean_us = encode_mean_us.unwrap_or(-1),
|
||||
actual_kbps,
|
||||
flushed = flush_in_window,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
|
||||
@@ -14,6 +14,9 @@ pub(super) async fn run(
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
// `host_timing_tx`: that channel is the overlay's, lossy and embedder-drained.
|
||||
encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
|
||||
) {
|
||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||
@@ -74,6 +77,11 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
||||
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
||||
if let Some(s) = &t.stages {
|
||||
let mut acc = encode_lat.lock().unwrap();
|
||||
acc.sum_us += s.encode_us as u64;
|
||||
acc.count += 1;
|
||||
}
|
||||
let _ = host_timing_tx.try_send(t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
|
||||
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
|
||||
pub(super) struct HandshakeOut {
|
||||
pub(super) conn: quinn::Connection,
|
||||
/// The dialing endpoint, kept alive for the session so the pump can FLUSH its
|
||||
/// `CONNECTION_CLOSE` before the runtime is dropped ([`super::run_pump`]). Dropping it here
|
||||
/// left nothing to drive the close onto the wire, so a deliberate quit reached the host as
|
||||
/// silence — an 8 s idle timeout with no quit code, which the host reads as an unwanted
|
||||
/// disconnect and lingers the display for.
|
||||
pub(super) ep: quinn::Endpoint,
|
||||
pub(super) session: Session,
|
||||
pub(super) ctrl_send: quinn::SendStream,
|
||||
pub(super) ctrl_recv: io::MsgReader,
|
||||
@@ -238,6 +243,7 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
match handshake.await {
|
||||
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
|
||||
conn,
|
||||
ep,
|
||||
session,
|
||||
ctrl_send: send,
|
||||
ctrl_recv: recv,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||
//! getting the legacy per-transition gamepad events.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn run(
|
||||
|
||||
@@ -124,6 +124,11 @@ pub fn capture_virtual_output(
|
||||
virtual-display warnings above)"
|
||||
)
|
||||
})?;
|
||||
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
|
||||
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
|
||||
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
|
||||
// degraded to the keep-physicals fallback) — the pen-offset field bug.
|
||||
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
|
||||
|
||||
@@ -220,12 +220,13 @@ pub fn start(
|
||||
rikeyid: i32,
|
||||
params: AudioParams,
|
||||
audio_cap: AudioCapSlot,
|
||||
on_lost: super::OnSessionLost,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
tracing::info!(?params, "audio stream starting");
|
||||
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap) {
|
||||
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost) {
|
||||
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
@@ -243,6 +244,7 @@ pub fn start(
|
||||
_rikeyid: i32,
|
||||
_params: AudioParams,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_on_lost: super::OnSessionLost,
|
||||
) {
|
||||
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
|
||||
running.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -255,6 +257,7 @@ fn run(
|
||||
rikeyid: i32,
|
||||
params: AudioParams,
|
||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
|
||||
@@ -296,7 +299,7 @@ fn run(
|
||||
}
|
||||
None => audio::open_audio_capture(want).context("open audio capture")?,
|
||||
};
|
||||
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running);
|
||||
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running, on_lost);
|
||||
cap.idle(); // parked between sessions — release the routing claim (Linux stream sink)
|
||||
audio::park_audio_capture(audio_cap, cap); // drop on Windows (restores the default), keep on Linux
|
||||
result
|
||||
@@ -355,6 +358,7 @@ impl SessionEncoder {
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn audio_body(
|
||||
cap: &mut dyn AudioCapturer,
|
||||
sock: &UdpSocket,
|
||||
@@ -362,6 +366,9 @@ fn audio_body(
|
||||
rikeyid: i32,
|
||||
params: AudioParams,
|
||||
running: &AtomicBool,
|
||||
// Whole-session teardown for the client-unreachable send errors below — video would
|
||||
// otherwise keep streaming at the dead endpoint (see `AppState::end_session`).
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
let layout = layout_for(¶ms);
|
||||
let mut enc = SessionEncoder::new(layout)?;
|
||||
@@ -427,7 +434,8 @@ fn audio_body(
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(&out[..n]);
|
||||
let pkt = build_rtp(seq, timestamp, &ct);
|
||||
if sock.send(&pkt).is_err() {
|
||||
tracing::info!(sent, "audio: client unreachable — stopping");
|
||||
tracing::info!(sent, "audio: client unreachable — ending session");
|
||||
on_lost();
|
||||
return Ok(());
|
||||
}
|
||||
// Surround FEC: accumulate the encrypted payloads of the aligned 4-packet block;
|
||||
@@ -449,7 +457,11 @@ fn audio_body(
|
||||
let fp =
|
||||
build_fec_rtp(rtp_seq, x as u8, fec_base_seq, fec_base_ts, par);
|
||||
if sock.send(&fp).is_err() {
|
||||
tracing::info!(sent, "audio: client unreachable — stopping");
|
||||
tracing::info!(
|
||||
sent,
|
||||
"audio: client unreachable — ending session"
|
||||
);
|
||||
on_lost();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,34 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Connect { peer: p, .. } => {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
// Track this peer as THE session peer only if it comes from the
|
||||
// `/launch` owner's IP (when captured — `None` falls back to
|
||||
// trusting the connect, the pre-teardown behavior). The tracked
|
||||
// peer's disconnect now ENDS the session, so an unauthenticated
|
||||
// LAN peer that connects+disconnects on 47999 must not be able to
|
||||
// steal the slot and tear a live session down. Same source-IP
|
||||
// bind the RTSP/media plane uses (security-review #4).
|
||||
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||||
let from = p.address().map(|a| a.ip());
|
||||
if owner_ip.is_some() && from.is_some() && owner_ip != from {
|
||||
tracing::warn!(
|
||||
?from,
|
||||
"control: peer connected from a non-owner IP — ignoring"
|
||||
);
|
||||
} else {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
}
|
||||
}
|
||||
Event::Disconnect { .. } => {
|
||||
Event::Disconnect { peer: p, .. } => {
|
||||
// Gate on the TRACKED session peer: a stray probe peer (or the
|
||||
// OLD peer's late timeout after a fast reconnect replaced it in
|
||||
// the Connect arm) must neither clobber the live session's input
|
||||
// state nor end its session.
|
||||
if peer != Some(p.id()) {
|
||||
tracing::debug!("control: non-session peer disconnected");
|
||||
continue;
|
||||
}
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
@@ -97,6 +121,16 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// The control stream is the session's liveness anchor — Moonlight
|
||||
// holds it for the whole stream, and ENet detects a vanished peer
|
||||
// via its reliable-ping timeout (~5–30 s), which ALSO lands here.
|
||||
// End the session: without this, a client that disconnects without
|
||||
// an explicit RTSP TEARDOWN / nvhttp `/cancel` (a network drop,
|
||||
// sleep, crash — or just a plain Moonlight quit, which sends
|
||||
// neither) left the media threads streaming at the dead endpoint
|
||||
// forever (a UDP send only errors on an ICMP port-unreachable) and
|
||||
// the stale launch/streaming state wedged every reconnect.
|
||||
state.end_session("control stream disconnected");
|
||||
}
|
||||
Event::Receive {
|
||||
channel_id, packet, ..
|
||||
|
||||
@@ -182,7 +182,46 @@ pub struct AppState {
|
||||
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
}
|
||||
|
||||
/// Session-lost callback the media threads invoke when they detect the client is unreachable
|
||||
/// (a UDP send error): ends the WHOLE GameStream session via [`AppState::end_session`], not just
|
||||
/// the thread that noticed — video and audio otherwise stop independently and leave the launch
|
||||
/// state behind. Built by the RTSP PLAY handler (the one place with the `Arc<AppState>`).
|
||||
pub(crate) type OnSessionLost = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
impl AppState {
|
||||
/// End the GameStream session as one unit: signal BOTH media threads to stop (they observe
|
||||
/// their `streaming`/`audio_streaming` flags) and clear the launch + negotiated stream
|
||||
/// config. Idempotent — safe to call from every "the client is gone" site.
|
||||
///
|
||||
/// This is THE teardown for the compat plane. Anything less leaves a stale session behind:
|
||||
/// a lingering `launch` 503-blocks a different client's `/launch` under
|
||||
/// `mode_conflict = reject`, and a stale `streaming = true` makes a reconnect's RTSP PLAY
|
||||
/// take its "stream already running" branch while the old threads still stream at the
|
||||
/// vanished client's endpoint (no new threads are started — the reconnect gets no media).
|
||||
/// Returns whether the video stream was live (for the caller's log line).
|
||||
pub(crate) fn end_session(&self, reason: &str) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
let was_streaming = self.streaming.swap(false, Ordering::SeqCst);
|
||||
let was_audio = self.audio_streaming.swap(false, Ordering::SeqCst);
|
||||
let had_launch = self
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
.is_some();
|
||||
self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
if was_streaming || was_audio || had_launch {
|
||||
tracing::info!(
|
||||
reason,
|
||||
was_streaming,
|
||||
was_audio,
|
||||
had_launch,
|
||||
"gamestream: session ended"
|
||||
);
|
||||
}
|
||||
was_streaming
|
||||
}
|
||||
|
||||
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
||||
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
||||
/// mgmt API and the streaming loops.
|
||||
@@ -429,6 +468,69 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
let host = Host {
|
||||
hostname: "test-host".into(),
|
||||
uniqueid: "deadbeef".into(),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
http_port: HTTP_PORT,
|
||||
https_port: HTTPS_PORT,
|
||||
};
|
||||
let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity");
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
|
||||
"pf-gs-endsession-{}-{:p}",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
)));
|
||||
AppState::new(host, identity, stats)
|
||||
}
|
||||
|
||||
/// `end_session` is THE compat-plane teardown: one call must clear the whole session — both
|
||||
/// media-thread flags, the launch, and the negotiated stream config — and be idempotent.
|
||||
/// Guards the ENet-Disconnect / client-unreachable paths that previously stopped nothing
|
||||
/// (the "session stays alive after the client disconnects" bug).
|
||||
#[test]
|
||||
fn end_session_clears_the_whole_session() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let state = test_state();
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
state.audio_streaming.store(true, Ordering::SeqCst);
|
||||
*state.launch.lock().unwrap() = Some(LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
owner_fp: None,
|
||||
});
|
||||
*state.stream.lock().unwrap() = Some(stream::StreamConfig {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
packet_size: 1024,
|
||||
bitrate_kbps: 20_000,
|
||||
codec: crate::encode::Codec::H265,
|
||||
min_fec: 0,
|
||||
hdr: false,
|
||||
});
|
||||
|
||||
assert!(state.end_session("test"), "video was live");
|
||||
assert!(!state.streaming.load(Ordering::SeqCst));
|
||||
assert!(!state.audio_streaming.load(Ordering::SeqCst));
|
||||
assert!(state.launch.lock().unwrap().is_none());
|
||||
assert!(state.stream.lock().unwrap().is_none());
|
||||
|
||||
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
|
||||
assert!(!state.end_session("test again"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -250,14 +250,9 @@ async fn h_cancel(
|
||||
tracing::warn!("cancel rejected — caller does not own the session");
|
||||
return xml(error_xml());
|
||||
}
|
||||
*st.launch.lock().unwrap() = None;
|
||||
// Quit semantics: stop the running media threads (they observe these flags) so the session
|
||||
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||
st.streaming
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
st.audio_streaming
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
tracing::info!("cancel — launch session cleared, streams stopping");
|
||||
// Quit semantics: the shared full teardown (launch cleared + both media threads stop on
|
||||
// their flags) — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||
st.end_session("client /cancel");
|
||||
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,9 @@ fn authorized_launch(state: &AppState, peer: Option<SocketAddr>) -> Option<Launc
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
||||
// `&Arc<AppState>` (not `&AppState`): PLAY hands the media threads a `'static` session-lost
|
||||
// callback, which needs an owned clone of the state.
|
||||
fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>) -> String {
|
||||
match req.method.as_str() {
|
||||
"OPTIONS" => response(
|
||||
&req.cseq,
|
||||
@@ -254,6 +256,15 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
};
|
||||
let cfg = *state.stream.lock().unwrap();
|
||||
// Client-unreachable teardown for the media threads: ends the WHOLE session (both
|
||||
// planes + launch state), so one plane detecting the dead client can't leave the
|
||||
// other streaming at it — or leave a stale launch to wedge the next connect.
|
||||
let on_lost: super::OnSessionLost = {
|
||||
let st = state.clone();
|
||||
Arc::new(move || {
|
||||
st.end_session("client unreachable");
|
||||
})
|
||||
};
|
||||
match cfg {
|
||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||
// Resolve the launched catalog entry (session recipe) for the stream.
|
||||
@@ -267,6 +278,7 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
state.rfi_range.clone(),
|
||||
state.video_cap.clone(),
|
||||
state.stats.clone(),
|
||||
on_lost.clone(),
|
||||
);
|
||||
}
|
||||
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
||||
@@ -283,6 +295,7 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
ls.rikeyid,
|
||||
*state.audio_params.lock().unwrap(),
|
||||
state.audio_cap.clone(),
|
||||
on_lost,
|
||||
);
|
||||
}
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
|
||||
@@ -70,6 +70,24 @@ fn apply_hdr(base: u32, hdr: bool) -> u32 {
|
||||
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
||||
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||
fn base_codec_mode_support() -> u32 {
|
||||
// A GPU-less host encodes H.264 and nothing else (openh264), so advertising the superset made
|
||||
// Moonlight negotiate HEVC/AV1 and the session then died at encoder open with "the software
|
||||
// encoder emits H.264 only". `pf_encode::Codec::host_wire_caps` — the native plane's twin of
|
||||
// this function — has gated on exactly this since it was written; this one never did.
|
||||
//
|
||||
// Deliberately a local gate rather than delegating wholesale to `host_wire_caps()`: that would
|
||||
// be the drift-proof shape, but on Windows it re-runs the DXGI adapter enumeration several
|
||||
// times per `/serverinfo` GET (the probe helpers each sample it), and this endpoint is polled.
|
||||
// The software case is a plain config read, so it costs nothing here. (Follow-up worth doing:
|
||||
// the static `MaxLumaPixelsHEVC` in the XML above still advertises an HEVC limit even when the
|
||||
// mask drops HEVC — harmless, since Moonlight gates on the mask, but it is a second and now
|
||||
// inconsistent advertisement.)
|
||||
if matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
) {
|
||||
return super::SCM_H264;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||
/// the persistent capturer the thread borrows for the stream's duration.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
cfg: StreamConfig,
|
||||
app: Option<super::apps::AppEntry>,
|
||||
@@ -53,6 +54,7 @@ pub fn start(
|
||||
rfi_range: RfiSlot,
|
||||
video_cap: CapturerSlot,
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
on_lost: super::OnSessionLost,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-video".into())
|
||||
@@ -103,6 +105,7 @@ pub fn start(
|
||||
&rfi_range,
|
||||
&video_cap,
|
||||
&stats,
|
||||
&on_lost,
|
||||
);
|
||||
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
||||
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
||||
@@ -137,6 +140,8 @@ fn run(
|
||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||
// encode loop); per-frame sample emission is wired by a later pass.
|
||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||
// Whole-session teardown for the send thread's client-unreachable detection.
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||
pf_frame::session_tuning::on_hot_thread();
|
||||
@@ -252,6 +257,7 @@ fn run(
|
||||
rfi_range,
|
||||
stats,
|
||||
&client_label,
|
||||
on_lost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +305,7 @@ fn run(
|
||||
rfi_range,
|
||||
stats,
|
||||
&client_label,
|
||||
on_lost,
|
||||
);
|
||||
capturer.set_active(false);
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
||||
@@ -572,12 +579,15 @@ fn spawn_packetizer(
|
||||
/// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
|
||||
/// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
|
||||
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
|
||||
/// of the fixed budget. On send failure (client gone) it clears `running`.
|
||||
/// of the fixed budget. On send failure (client gone) it ends the whole session via `on_lost` —
|
||||
/// not just this thread: audio would otherwise keep streaming at the dead endpoint and the stale
|
||||
/// launch state would wedge the next connect (see `AppState::end_session`).
|
||||
fn spawn_sender(
|
||||
sock: UdpSocket,
|
||||
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
||||
frame_interval: Duration,
|
||||
running: Arc<AtomicBool>,
|
||||
on_lost: super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-send".into())
|
||||
@@ -613,8 +623,9 @@ fn spawn_sender(
|
||||
},
|
||||
);
|
||||
if let Err(e) = r {
|
||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
||||
tracing::info!(error = %e, sent, "video: client unreachable — ending session");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
on_lost();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -645,6 +656,8 @@ fn stream_body(
|
||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
|
||||
client_label: &str,
|
||||
// Whole-session teardown, handed to the send thread's client-unreachable detection.
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
// The first frame establishes the authoritative size/format for the encoder.
|
||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||
@@ -673,6 +686,13 @@ fn stream_body(
|
||||
true,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||
// (Windows direct-NVENC, which encodes the capturer's textures with no CopyResource) bounds
|
||||
// itself by an env cap instead of the ring it is actually reading, and the capturer rotates a
|
||||
// texture out from under a live encode — torn/mixed frames, never an error. The backend now
|
||||
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
|
||||
// and a deeper ring is free pipelining the fallback would forfeit.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
@@ -708,6 +728,7 @@ fn stream_body(
|
||||
batch_rx,
|
||||
Duration::from_secs_f64(1.0 / target_fps as f64),
|
||||
running.clone(),
|
||||
on_lost.clone(),
|
||||
)?;
|
||||
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
||||
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
||||
@@ -827,6 +848,8 @@ fn stream_body(
|
||||
true, // metadata-cursor capture — see the first open
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
@@ -1075,6 +1098,7 @@ mod tests {
|
||||
rx,
|
||||
Duration::from_millis(8), // ~120fps frame interval
|
||||
running.clone(),
|
||||
Arc::new(|| {}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
|
||||
pub(crate) struct LocalSummary {
|
||||
/// Host version (mirrors `/health`).
|
||||
version: String,
|
||||
/// True while the video stream thread is running.
|
||||
/// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live
|
||||
/// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone.
|
||||
video_streaming: bool,
|
||||
/// True while the audio stream thread is running.
|
||||
/// True while audio is streaming on either plane (same rule as `video_streaming`).
|
||||
audio_streaming: bool,
|
||||
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
|
||||
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
|
||||
/// live native session. `null` when nothing is streaming.
|
||||
session: Option<SessionInfo>,
|
||||
/// Number of pinned (paired) GameStream client certificates.
|
||||
paired_clients: u32,
|
||||
@@ -391,26 +393,27 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||
// Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for
|
||||
// both the session card and the streaming flags below.
|
||||
let native = crate::session_status::snapshot();
|
||||
// GameStream launch, else the first live native session — so the tray reflects a native session
|
||||
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||
let session = st
|
||||
.app
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.map(|l| SessionInfo {
|
||||
width: l.width,
|
||||
height: l.height,
|
||||
fps: l.fps,
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::session_status::snapshot()
|
||||
.first()
|
||||
.map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
native.first().map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
})
|
||||
});
|
||||
let (native_paired_clients, pending_approvals) = st
|
||||
.native
|
||||
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
||||
.unwrap_or((0, 0));
|
||||
Json(LocalSummary {
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
|
||||
// "idle" (and wear the idle icon) through an entire native session.
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
session,
|
||||
paired_clients: st
|
||||
.app
|
||||
|
||||
@@ -19,11 +19,8 @@ use std::sync::atomic::Ordering;
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
||||
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
||||
*st.app.launch.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*st.app.stream.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Native plane: the GameStream flags above don't reach it (it runs its own loops off the shared
|
||||
let was_streaming = st.app.end_session("management API stop");
|
||||
// Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared
|
||||
// session registry), so signal every live native session to tear down too.
|
||||
let native = crate::session_status::count();
|
||||
crate::session_status::stop_all();
|
||||
|
||||
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
|
||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
||||
}
|
||||
|
||||
/// Serializes the tests that read (or write) the process-global live-session registry
|
||||
/// ([`crate::session_status`]): a session registered by one test would otherwise make a
|
||||
/// concurrently running one see a stream it never started.
|
||||
static SESSION_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A `/local/summary` request from a loopback peer (the tray's own).
|
||||
fn summary_req() -> axum::http::Request<Body> {
|
||||
let mut req = get_req("/api/v1/local/summary");
|
||||
req.extensions_mut()
|
||||
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
/// Registers a stand-in live native session; the returned guard removes it on drop.
|
||||
fn fake_native_session(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
) -> crate::session_status::LiveSessionGuard {
|
||||
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
|
||||
crate::session_status::register(
|
||||
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
|
||||
Codec::H265,
|
||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
"test-client".into(),
|
||||
false,
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
|
||||
/// summary. The GameStream `streaming` flag stays false throughout such a session, and reading it
|
||||
/// alone left the tray showing "idle" (with the idle icon) for the whole stream: exactly the blind
|
||||
/// spot `/status` was fixed for in [`crate::session_status`], which `/local/summary` still had.
|
||||
#[tokio::test]
|
||||
async fn local_summary_reports_a_native_session_as_streaming() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
|
||||
let session = fake_native_session(3840, 2160, 120);
|
||||
let (_, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(body["video_streaming"], true, "native session: {body}");
|
||||
assert_eq!(body["audio_streaming"], true, "native session: {body}");
|
||||
assert_eq!(body["session"]["width"], 3840);
|
||||
assert_eq!(body["session"]["height"], 2160);
|
||||
assert_eq!(body["session"]["fps"], 120);
|
||||
|
||||
// Session over → back to idle.
|
||||
drop(session);
|
||||
let (_, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||
#[tokio::test]
|
||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
||||
@@ -600,6 +663,7 @@ async fn compositors_lists_all_backends_with_flags() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_reflects_runtime_state() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
|
||||
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn idr_requires_an_active_stream() {
|
||||
// A live native session (registered by a sibling test) is an active stream to this route.
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
let post = || {
|
||||
|
||||
@@ -392,11 +392,6 @@ pub(crate) async fn serve(
|
||||
);
|
||||
|
||||
loop {
|
||||
let permit = sem
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("session semaphore is never closed");
|
||||
let incoming = match ep.accept().await {
|
||||
Some(i) => i,
|
||||
None => break, // endpoint closed
|
||||
@@ -409,9 +404,18 @@ pub(crate) async fn serve(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "QUIC accept failed");
|
||||
continue; // `permit` drops here → slot freed; not counted toward max_sessions
|
||||
continue; // not counted toward max_sessions
|
||||
}
|
||||
};
|
||||
// Take the session slot only AFTER the handshake, so a full host still ACCEPTS the
|
||||
// connection and the waiting client sees a live path (quinn's keep-alive holds it) instead
|
||||
// of a silent dial timeout — previously the loop parked on this await before `accept()`, so
|
||||
// a host at its concurrency cap looked simply unreachable.
|
||||
let permit = sem
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("session semaphore is never closed");
|
||||
let peer = conn.remote_address();
|
||||
tracing::info!(%peer, "punktfunk/1 client connected");
|
||||
let opts = opts.clone();
|
||||
@@ -486,6 +490,31 @@ pub(crate) async fn serve(
|
||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// How long the stream thread may still run AFTER its session was told to stop before
|
||||
/// [`serve_session`] gives up waiting for it.
|
||||
///
|
||||
/// Must exceed every legitimate post-stop path so a slow-but-healthy teardown is never abandoned:
|
||||
/// the capture-loss rebuild budget is 40 s and one pipeline-build attempt can take ~10 s on a cold
|
||||
/// compositor, so 90 s leaves generous headroom.
|
||||
const STREAM_STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(90);
|
||||
|
||||
/// How long teardown waits for the audio + input threads once the connection is closed. They exit
|
||||
/// promptly by construction (the audio loop checks `stop` every ≤5 s; the input thread's channel
|
||||
/// drops with the connection), so this only catches a genuine wedge.
|
||||
const SIDE_THREAD_JOIN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Resolves once `stop` has been set for [`STREAM_STOP_GRACE`] — i.e. the session was told to end
|
||||
/// and its stream thread *still* hasn't returned.
|
||||
///
|
||||
/// Polled rather than notified: `stop` is a plain flag shared with blocking threads, and the poll
|
||||
/// only runs while a session is live (every 500 ms, one relaxed atomic load).
|
||||
async fn stop_overdue(stop: &AtomicBool) {
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
tokio::time::sleep(STREAM_STOP_GRACE).await;
|
||||
}
|
||||
|
||||
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
|
||||
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
|
||||
/// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
|
||||
@@ -956,6 +985,18 @@ async fn serve_session(
|
||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
||||
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
||||
// Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes
|
||||
// here; the control task reads at `SetBitrate`-resolve time, so the ack the client's
|
||||
// controller climbs from tracks what the encoder ACTUALLY does, not what was asked:
|
||||
// - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view.
|
||||
// - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet);
|
||||
// resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds.
|
||||
// - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at
|
||||
// the current rate); the network isn't the bottleneck, more bits are anti-medicine.
|
||||
// Plain atomics, not a channel: only the freshest value matters, and only at resolve time.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
|
||||
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
|
||||
let cadence_degraded = Arc::new(AtomicBool::new(false));
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1007,6 +1048,9 @@ async fn serve_session(
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
fec_target_ctl,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
@@ -1330,7 +1374,7 @@ async fn serve_session(
|
||||
let bringup_dp = bringup.clone();
|
||||
let resize_ms_dp = resize_ms.clone();
|
||||
let result: Result<()> = async {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
||||
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
||||
@@ -1400,6 +1444,9 @@ async fn serve_session(
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
@@ -1444,9 +1491,34 @@ async fn serve_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("stream thread")??;
|
||||
});
|
||||
// `stop` is only ADVISORY: the stream thread observes it between iterations, so a call that
|
||||
// blocks without a bound INSIDE one (a compositor CLI that never returns, a D-Bus round-trip
|
||||
// on a stuck bus, a driver wait on a hung GPU) never reaches the check — and nothing else
|
||||
// can end the session, because every teardown below runs only once this await resolves. That
|
||||
// made one stuck syscall a permanent zombie: it kept its semaphore slot (four of them and the
|
||||
// host stops accepting entirely), its admission entry (a later client gets "host busy"
|
||||
// forever) and its stream marker, and even the console's Stop button — which just sets this
|
||||
// same flag — could not clear it.
|
||||
//
|
||||
// So bound the wait: once the session HAS been told to stop, give the thread
|
||||
// `STREAM_STOP_GRACE` to return, then stop waiting for it and let teardown run. The thread is
|
||||
// detached, not killed (a blocking thread can't be cancelled in Rust) — it keeps its capturer
|
||||
// and encoder until the stuck call returns, and its own guards unwind if it ever does. That
|
||||
// is a leak, but a bounded one: the session's slot and admission entry come back, so the rest
|
||||
// of the host keeps serving.
|
||||
tokio::select! {
|
||||
joined = stream_thread => joined.context("stream thread")??,
|
||||
() = stop_overdue(&stop) => {
|
||||
tracing::error!(
|
||||
grace_s = STREAM_STOP_GRACE.as_secs(),
|
||||
"stream thread has not returned since the session was stopped — abandoning it so \
|
||||
the session slot is freed. Its capture/encoder stay held until the stuck call \
|
||||
returns; this is a HOST WEDGE — please report it with the log above"
|
||||
);
|
||||
anyhow::bail!("stream thread wedged after stop");
|
||||
}
|
||||
}
|
||||
// Give the client a moment to drain before the close.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(())
|
||||
@@ -1462,13 +1534,25 @@ async fn serve_session(
|
||||
if result.is_ok() { 0u32 } else { 1u32 }.into(),
|
||||
if result.is_ok() { b"done" } else { b"error" },
|
||||
);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Bounded, for the same reason the stream-thread wait is: the input thread exits only when the
|
||||
// datagram task drops its channel, which the `conn.close()` above forces — but a join is the
|
||||
// last unbounded await in teardown, and one stuck side thread must not hold the session's
|
||||
// permit/admission entry (released when this fn returns) hostage.
|
||||
let side_threads = tokio::task::spawn_blocking(move || {
|
||||
if let Some(h) = audio_handle {
|
||||
let _ = h.join();
|
||||
}
|
||||
let _ = input_handle.join();
|
||||
})
|
||||
.await;
|
||||
});
|
||||
if tokio::time::timeout(SIDE_THREAD_JOIN_GRACE, side_threads)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
grace_s = SIDE_THREAD_JOIN_GRACE.as_secs(),
|
||||
"audio/input threads did not exit after the connection closed — detaching them"
|
||||
);
|
||||
}
|
||||
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
|
||||
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
||||
// TV's gaming session back so it's the default when no one is streaming.
|
||||
|
||||
@@ -22,6 +22,13 @@ pub(super) async fn run(
|
||||
live_reconfig_ok: bool,
|
||||
adaptive_fec: bool,
|
||||
session_bitrate_kbps: u32,
|
||||
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
|
||||
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
|
||||
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
|
||||
// climbs from — never promises a rate the encoder won't run at.
|
||||
live_bitrate: Arc<AtomicU32>,
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
@@ -161,7 +168,31 @@ pub(super) async fn run(
|
||||
);
|
||||
session_bitrate_kbps
|
||||
} else {
|
||||
resolve_bitrate_kbps(req.bitrate_kbps)
|
||||
let mut r = resolve_bitrate_kbps(req.bitrate_kbps);
|
||||
// Encoder truth (§ABR overdrive): the ack below is the base the
|
||||
// client's controller climbs from, so it must not promise past the
|
||||
// encoder's discovered codec-level ceiling — the pre-fix path acked
|
||||
// 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed
|
||||
// from the phantom number forever (a ~0.6 s rebuild + IDR per step).
|
||||
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||
if ceiling != 0 && r > ceiling {
|
||||
r = ceiling;
|
||||
}
|
||||
// Climb refusal while encode can't hold cadence: on a fat LAN no
|
||||
// network signal ever stops the climb, and past the compute knee more
|
||||
// bits only deepen the miss. Resolve a CLIMB to the current applied
|
||||
// rate (descents pass — they're the cure); the short ack teaches the
|
||||
// client controller its ceiling.
|
||||
let live = live_bitrate.load(Ordering::Relaxed);
|
||||
if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live {
|
||||
tracing::info!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
held_kbps = live,
|
||||
"bitrate climb refused — encode is behind cadence"
|
||||
);
|
||||
r = live;
|
||||
}
|
||||
r
|
||||
};
|
||||
tracing::debug!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
|
||||
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
|
||||
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
|
||||
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
|
||||
/// what the ASIC really targets, not what was requested (§ABR overdrive).
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
/// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written
|
||||
/// when an apply comes back short, read by this loop (pre-clamp incoming requests — a
|
||||
/// request already AT the ceiling then costs nothing) and by the control task (truthful
|
||||
/// acks from the first post-discovery request).
|
||||
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
/// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the
|
||||
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
|
||||
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
|
||||
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
mut bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -1254,9 +1271,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
||||
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
|
||||
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
|
||||
// encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth.
|
||||
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
||||
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
||||
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
||||
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
const DEPTH_ESCALATE: u32 = 20;
|
||||
const DEPTH_BEHIND_CAP: u32 = 60;
|
||||
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
||||
// Half the escalate threshold: ~10 net behind-frames is already solid "the encoder, not the
|
||||
// network, is the bottleneck" evidence — enough to flag `cadence_degraded` (the control task
|
||||
// then refuses bitrate CLIMBS) well before the session pays a latency escalation for it.
|
||||
const DEPTH_DEGRADE: u32 = 10;
|
||||
// De-escalation (the escalate-and-hold v1's missing half): a sustained clean run at the
|
||||
// escalated setting (~5 s at 120 fps, every frame on cadence) earns ONE attempt at winding
|
||||
// back — reverse order of the escalation, pipelined retrieve first (its rebuild restores
|
||||
// sub-frame streaming and the IO-stream binding), then capture depth back to 1. Each
|
||||
// attempt costs the wind-back rebuild's IDR, so attempts are paced by an exponential
|
||||
// backoff (1 → 5 → 25 min, capped) — a workload that genuinely needs the escalation
|
||||
// converges to keeping it, but NEVER a permanent latch: a latch plus the ABR sawtooth
|
||||
// pinned sessions at the floor with the escalation stuck.
|
||||
const DEESCALATE_CLEAN_FRAMES: u32 = 600;
|
||||
const DEESCALATE_BACKOFF_START: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const DEESCALATE_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(25 * 60);
|
||||
let mut pipelined_active = false;
|
||||
let mut deescalating = false;
|
||||
let mut ahead_run: u32 = 0;
|
||||
let mut deescalate_not_before: Option<std::time::Instant> = None;
|
||||
let mut deescalate_backoff = DEESCALATE_BACKOFF_START;
|
||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
while let Ok(k) = bitrate_rx.try_recv() {
|
||||
want_kbps = Some(k);
|
||||
}
|
||||
// Known-ceiling pre-clamp (§ABR overdrive): once the encoder's codec-level ceiling is
|
||||
// known, resolve an over-asking request HERE — a request that clamps to the rate we're
|
||||
// already at then skips the whole apply, where the pre-fix path bounced every overshoot
|
||||
// off the driver into a full rebuild + IDR (~0.6 s each, four in one logged minute).
|
||||
// (The control task clamps its acks from the same atomic; this covers requests already
|
||||
// in flight when the ceiling was discovered.)
|
||||
if let Some(k) = want_kbps.as_mut() {
|
||||
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||
if ceiling != 0 && *k > ceiling {
|
||||
tracing::info!(
|
||||
requested_kbps = *k,
|
||||
ceiling_kbps = ceiling,
|
||||
"bitrate request clamped to the known encoder ceiling"
|
||||
);
|
||||
*k = ceiling;
|
||||
}
|
||||
}
|
||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
||||
// Adopt the encoder's post-clamp truth, not the request: it feeds the send
|
||||
// pacer, the console/mgmt view and the control task's acks, and a short apply
|
||||
// teaches the ceiling used above.
|
||||
let applied_kbps = enc
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
to_kbps = applied_kbps,
|
||||
requested_kbps = new_kbps,
|
||||
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
||||
);
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
}
|
||||
if applied_kbps < bitrate_kbps {
|
||||
// Down-step: the behind-cadence backlog was scored against the old,
|
||||
// heavier rate — clean slate so it can't feed a false escalation.
|
||||
behind_score = 0;
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
||||
} else {
|
||||
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan.cursor_blend,
|
||||
) {
|
||||
Ok(mut new_enc) => {
|
||||
// The fresh encoder may have clamped to its codec-level ceiling —
|
||||
// adopt (and record) ITS rate, not the request; see the in-place arm.
|
||||
let applied_kbps = new_enc
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
to_kbps = applied_kbps,
|
||||
requested_kbps = new_kbps,
|
||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||
);
|
||||
if let Some(c) = plan.wire_chunk {
|
||||
@@ -1714,8 +1793,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// directly so an ABR rebuild re-establishes the bound immediately.)
|
||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc = new_enc;
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a
|
||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
||||
// IDR cooldown too.
|
||||
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
// The rebuild stall itself (~0.6 s ≈ 70 missed deadlines at 120 fps,
|
||||
// 3.5× the escalate threshold) must not feed the contention
|
||||
// escalation — clean slate + re-run the warmup before judging again.
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
@@ -1940,6 +2028,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||
// appears — no reconnect.
|
||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||
// A managed/attach gamescope (re)launch legitimately takes up to 45 s — the Steam
|
||||
// Big Picture cold start that `launch_session`/`ensure_box_gamescope_mode` poll
|
||||
// for — so the 40 s budget used to expire INSIDE the first attempt (a single-shot
|
||||
// failure ending the session even when a second, warm attempt would have
|
||||
// succeeded). Give gamescope-targeted rebuilds room for two full launch attempts;
|
||||
// desktop compositors keep the tighter budget. Checked per iteration because the
|
||||
// loop retargets `compositor` as re-detection follows the box.
|
||||
const GAMESCOPE_REBUILD_BUDGET: std::time::Duration =
|
||||
std::time::Duration::from_secs(100);
|
||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||
@@ -1948,7 +2045,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let loss_at = std::time::Instant::now();
|
||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
||||
// An explicit PUNKTFUNK_COMPOSITOR pin disables the re-detection below — the
|
||||
// stream cannot follow a session switch. When the live session no longer matches
|
||||
// the pin, say so loudly ONCE per loss: this rebuild can only retry the pinned
|
||||
// backend and will die at the budget (the "mid-stream switch to game mode kills
|
||||
// the stream" field reports all traced back to a stale pin).
|
||||
if pf_host_config::config().compositor.is_some() {
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
if crate::vdisplay::compositor_for_kind(active.kind) != Some(compositor) {
|
||||
tracing::warn!(
|
||||
pinned = compositor.id(),
|
||||
live = ?active.kind,
|
||||
"capture lost while PUNKTFUNK_COMPOSITOR pins the backend and the \
|
||||
live session no longer matches it — the pin disables \
|
||||
session-following, so this rebuild can only retry the pinned \
|
||||
backend; remove the pin to let the stream follow session switches"
|
||||
);
|
||||
}
|
||||
}
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||
@@ -2021,8 +2135,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
) {
|
||||
Ok(p) => break p,
|
||||
Err(e2) => {
|
||||
let budget = if compositor == crate::vdisplay::Compositor::Gamescope {
|
||||
GAMESCOPE_REBUILD_BUDGET
|
||||
} else {
|
||||
REBUILD_BUDGET
|
||||
};
|
||||
if stop.load(Ordering::SeqCst)
|
||||
|| std::time::Instant::now() >= rebuild_deadline
|
||||
|| std::time::Instant::now() >= loss_at + budget
|
||||
{
|
||||
return Err(e2)
|
||||
.context("capture lost — no compositor came up within the rebuild budget");
|
||||
@@ -2516,7 +2635,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
|
||||
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
|
||||
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
|
||||
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
|
||||
if idd_adaptive_enabled() {
|
||||
depth_frames += 1;
|
||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||
let behind = std::time::Instant::now() >= next;
|
||||
@@ -2525,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
} else {
|
||||
behind_score.saturating_sub(1)
|
||||
};
|
||||
if behind_score >= DEPTH_ESCALATE {
|
||||
let escalated = cur_depth > 1 || pipelined_active || deescalating;
|
||||
// Export "encode can't hold cadence" for the control task's climb refusal.
|
||||
// An escalated session stays flagged even with the bucket drained: its climb
|
||||
// headroom is spent, and letting climbs resume would saw against the
|
||||
// escalation and starve the de-escalation clean run below.
|
||||
cadence_degraded.store(
|
||||
escalated || behind_score >= DEPTH_DEGRADE,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if deescalating {
|
||||
// A requested wind-back completes at the encoder's drained safe point —
|
||||
// poll it (the call is a cheap latch check until then).
|
||||
if !enc.set_pipelined(false) {
|
||||
deescalating = false;
|
||||
pipelined_active = false;
|
||||
// Re-arm the ask: a future sustained overrun may escalate again (the
|
||||
// backoff below paces how soon another wind-back may follow it).
|
||||
pipeline_asked = false;
|
||||
tracing::info!(
|
||||
"encoder pipelined retrieve de-escalated — sync retrieve (and \
|
||||
sub-frame streaming, where armed) restored; re-monitoring cadence"
|
||||
);
|
||||
// The wind-back rebuild's own stall must not re-escalate on the spot.
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
}
|
||||
} else if behind_score >= DEPTH_ESCALATE
|
||||
&& (cur_depth < max_depth || !pipeline_asked)
|
||||
{
|
||||
if cur_depth < max_depth {
|
||||
cur_depth = max_depth;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||
(GPU contention); pipelining for the rest of the session (latency \
|
||||
(GPU contention); pipelining until cadence holds clean (latency \
|
||||
trade for throughput)"
|
||||
);
|
||||
} else {
|
||||
pipeline_asked = true;
|
||||
if enc.set_pipelined(true) {
|
||||
pipelined_active = enc.set_pipelined(true);
|
||||
if pipelined_active {
|
||||
tracing::info!(
|
||||
"encoder pipelined retrieve escalated — encode can't hold \
|
||||
cadence and the capturer has no depth to give; the encode wait \
|
||||
moves off the loop for the rest of the session (latency trade \
|
||||
moves off the loop until cadence holds clean (latency trade \
|
||||
for throughput)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Give the action time to take effect before judging again.
|
||||
behind_score = 0;
|
||||
ahead_run = 0;
|
||||
} else if escalated {
|
||||
// De-escalation: a sustained every-frame-on-cadence run at the escalated
|
||||
// setting is the evidence the contention passed (a lower ABR rate, the
|
||||
// game scene lightened) — wind back in reverse order, paced by the
|
||||
// exponential backoff (see the consts above).
|
||||
ahead_run = if behind { 0 } else { ahead_run + 1 };
|
||||
if ahead_run >= DEESCALATE_CLEAN_FRAMES
|
||||
&& deescalate_not_before.is_none_or(|t| std::time::Instant::now() >= t)
|
||||
{
|
||||
ahead_run = 0;
|
||||
deescalate_not_before =
|
||||
Some(std::time::Instant::now() + deescalate_backoff);
|
||||
deescalate_backoff = (deescalate_backoff * 5).min(DEESCALATE_BACKOFF_MAX);
|
||||
if pipelined_active {
|
||||
tracing::info!(
|
||||
"cadence held clean while escalated — winding the pipelined \
|
||||
retrieve back (latency recovery; costs one IDR)"
|
||||
);
|
||||
deescalating = true;
|
||||
} else if cur_depth > 1 {
|
||||
cur_depth = 1;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"IDD pipeline depth de-escalated — cadence held clean at the \
|
||||
escalated depth (latency recovery)"
|
||||
);
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
) {
|
||||
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
||||
}
|
||||
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
|
||||
for _ in 0..30 {
|
||||
if token_path.exists() {
|
||||
break;
|
||||
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
|
||||
//
|
||||
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
|
||||
// host writes them in that order: the token during argument parsing (`main::parse_serve`,
|
||||
// milliseconds after `serve` starts), the cert only once `serve` reaches
|
||||
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
|
||||
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
|
||||
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
|
||||
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
|
||||
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
|
||||
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
|
||||
// files are on disk before the first start.
|
||||
let cert_path = data_dir.join("cert.pem");
|
||||
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
|
||||
eprintln!(
|
||||
"warning: the host service has not written {} + {} yet - not starting the console now; \
|
||||
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
|
||||
token_path.display(),
|
||||
cert_path.display()
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
return Ok(());
|
||||
}
|
||||
if start_web_task() {
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
} else {
|
||||
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
|
||||
// console, so this warning is the only trace an operator gets at install time.
|
||||
eprintln!(
|
||||
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
|
||||
Result in Task Scheduler; the console will be retried at the next boot"
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
|
||||
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
|
||||
for _ in 0..secs {
|
||||
if paths.iter().all(|p| p.exists()) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
Ok(())
|
||||
paths.iter().all(|p| p.exists())
|
||||
}
|
||||
|
||||
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
|
||||
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
|
||||
/// up listening.
|
||||
fn start_web_task() -> bool {
|
||||
for _ in 0..3 {
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
|
||||
for _ in 0..10 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
if !web_listener_pids().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
||||
@@ -505,30 +558,57 @@ fn random_password() -> String {
|
||||
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
||||
fn stop_web_console() {
|
||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
if toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
|
||||
{
|
||||
let pid = toks[toks.len() - 1];
|
||||
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
|
||||
run_quiet("taskkill", &["/PID", pid, "/F"]);
|
||||
}
|
||||
}
|
||||
for pid in web_listener_pids() {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`,
|
||||
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||
fn web_listener_pids() -> Vec<String> {
|
||||
run_capture("netstat", &["-ano", "-p", "tcp"])
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
let listening = toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
|
||||
let pid = *toks.last()?;
|
||||
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| pid.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
|
||||
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
///
|
||||
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
|
||||
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
|
||||
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
|
||||
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
|
||||
/// fail registration outright (no task at all is strictly worse than a boot-only task).
|
||||
fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
let cmd_xml = xml_escape(&cmd.to_string_lossy());
|
||||
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
|
||||
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
|
||||
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
|
||||
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
|
||||
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
|
||||
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
|
||||
<Triggers>{triggers}</Triggers>\n\
|
||||
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
||||
<Settings>\n\
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||
</Settings>\n\
|
||||
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
||||
</Task>",
|
||||
xml_escape(&cmd.to_string_lossy())
|
||||
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
|
||||
</Task>"
|
||||
);
|
||||
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
|
||||
write_utf16le_bom(&xml_path, &xml)?;
|
||||
if write_utf16le_bom(&xml_path, &xml).is_err() {
|
||||
return false;
|
||||
}
|
||||
let ok = run_quiet(
|
||||
"schtasks",
|
||||
&[
|
||||
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
],
|
||||
);
|
||||
let _ = std::fs::remove_file(&xml_path);
|
||||
if ok {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
|
||||
struct HostTray {
|
||||
status: TrayStatus,
|
||||
web_port: u16,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe — labels the (always present) "Open
|
||||
/// web console" entry; it never hides it.
|
||||
web_console: bool,
|
||||
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
||||
/// force an immediate re-poll instead of waiting out the cadence.
|
||||
@@ -33,8 +33,10 @@ impl HostTray {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_console(&self) {
|
||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
|
||||
/// the menu entry promised — the pairing queue, the virtual displays.
|
||||
fn open_console(&self, path: &str) {
|
||||
let url = format!("https://127.0.0.1:{}/{path}", self.web_port);
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
}
|
||||
@@ -51,7 +53,7 @@ impl ksni::Tray for HostTray {
|
||||
fn status(&self) -> ksni::Status {
|
||||
match &self.status {
|
||||
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
||||
s if s.pairing_attention() || s.has_conflicts() => ksni::Status::NeedsAttention,
|
||||
s if s.pairing_attention() => ksni::Status::NeedsAttention,
|
||||
_ => ksni::Status::Active,
|
||||
}
|
||||
}
|
||||
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
// Always present — it is the reason most people open this menu. When the loopback
|
||||
// probe says the console isn't answering, the label says so rather than the entry
|
||||
// disappearing (a menu missing its main action reads as a broken tray).
|
||||
StandardItem {
|
||||
label: "Open web console".into(),
|
||||
visible: self.web_console,
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
label: if self.web_console {
|
||||
"Open web console".to_string()
|
||||
} else {
|
||||
"Open web console (not responding)".to_string()
|
||||
},
|
||||
activate: Box::new(|t: &mut Self| t.open_console("")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Approve pairing request…".into(),
|
||||
visible: self.web_console && self.status.pairing_attention(),
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
visible: self.status.pairing_attention(),
|
||||
activate: Box::new(|t: &mut Self| t.open_console("pairing")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: match self.status.kept_displays() {
|
||||
1 => "Release kept display…".to_string(),
|
||||
n => format!("Release {n} kept displays…"),
|
||||
},
|
||||
visible: self.status.kept_displays() > 0,
|
||||
activate: Box::new(|t: &mut Self| t.open_console("displays")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
|
||||
@@ -37,11 +37,6 @@ pub struct Summary {
|
||||
/// host that doesn't send it deserializes as 0.
|
||||
#[serde(default)]
|
||||
pub kept_displays: u32,
|
||||
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) the host detected on this machine at
|
||||
/// startup — side-by-side use is unsupported. `#[serde(default)]` so an older host omitting it
|
||||
/// deserializes as empty.
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||
@@ -74,47 +69,43 @@ impl TrayStatus {
|
||||
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||
TrayStatus::Running(s) => {
|
||||
let base = match (&s.session, s.video_streaming) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
|
||||
// exclusive topology, your physical monitors — is being held. Release it from
|
||||
// the console.
|
||||
_ if s.kept_displays > 0 => format!(
|
||||
"punktfunk host {} — idle · {} display{} kept",
|
||||
s.version,
|
||||
s.kept_displays,
|
||||
if s.kept_displays == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => format!("punktfunk host {} — idle", s.version),
|
||||
};
|
||||
// A conflicting Moonlight host (Sunshine/Apollo/…) is the loudest thing to say —
|
||||
// side-by-side use is unsupported, so lead the tooltip with it.
|
||||
if s.conflicts.is_empty() {
|
||||
base
|
||||
} else {
|
||||
format!("⚠ conflicting host: {} — {base}", s.conflicts.join(", "))
|
||||
}
|
||||
}
|
||||
TrayStatus::Running(s) => match (&s.session, self.is_streaming()) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
|
||||
// exclusive topology, your physical monitors — is being held. Release it from
|
||||
// the console.
|
||||
_ if s.kept_displays > 0 => format!(
|
||||
"punktfunk host {} — idle · {} display{} kept",
|
||||
s.version,
|
||||
s.kept_displays,
|
||||
if s.kept_displays == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => format!("punktfunk host {} — idle", s.version),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
|
||||
/// unsupported side-by-side. Drives the Linux (ksni) backend's `NeedsAttention` state; the
|
||||
/// Windows backend surfaces the same conflict through the tooltip `headline()` instead (it has
|
||||
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
|
||||
/// than gate the shared API out.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn has_conflicts(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
||||
/// A client is streaming: the host's flag, OR a live session in the summary.
|
||||
///
|
||||
/// The session is checked too because a host from before the `get_local_summary` fix raised
|
||||
/// `video_streaming` from the GameStream plane only — through a whole session on the native
|
||||
/// (default) plane it said false while still reporting that session's mode, which is what left
|
||||
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
|
||||
}
|
||||
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||
/// Virtual displays held with no live session (lingering/pinned) — offered as a one-click
|
||||
/// release in the menu, since holding one can also be keeping physical monitors dark.
|
||||
pub fn kept_displays(&self) -> u32 {
|
||||
match self {
|
||||
TrayStatus::Running(s) => s.kept_displays,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||
@@ -156,9 +147,11 @@ struct Shared {
|
||||
|
||||
impl Poller {
|
||||
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
|
||||
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
||||
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
|
||||
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
|
||||
/// is the tray's most-wanted action, and a menu that silently drops it — because the console
|
||||
/// was still starting, or the probe timed out — is indistinguishable from a tray that never
|
||||
/// had one.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
@@ -203,6 +196,10 @@ fn poll_loop(
|
||||
// When the summary became unreachable while the service was running (grace anchor).
|
||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||
let mut unreachable_since: Option<Instant> = None;
|
||||
// Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR
|
||||
// whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes
|
||||
// its label every few seconds reads as broken.
|
||||
let mut console_misses = 0u32;
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
@@ -219,7 +216,13 @@ fn poll_loop(
|
||||
};
|
||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||
let status = map_status(&svc, summary, grace_expired);
|
||||
let console_up = probe_console(&agent, &console_url);
|
||||
let console_up = if probe_console(&agent, &console_url) {
|
||||
console_misses = 0;
|
||||
true
|
||||
} else {
|
||||
console_misses += 1;
|
||||
console_misses < 2
|
||||
};
|
||||
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||
on_change(status.clone(), console_up);
|
||||
last = Some((status, console_up));
|
||||
@@ -406,7 +409,6 @@ mod tests {
|
||||
pin_pending: false,
|
||||
pending_approvals: 0,
|
||||
kept_displays: 0,
|
||||
conflicts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,15 +451,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Conflicting-host detection is deliberately NOT a tray concern: "installed" is not
|
||||
/// "running", and an always-on ⚠ over a merely-present Sunshine cried wolf. The host still
|
||||
/// detects and reports it (startup log, `detect-conflicts`, the mgmt summary) — the tray just
|
||||
/// ignores that field, which it must keep tolerating on the wire.
|
||||
#[test]
|
||||
fn conflicts_drive_attention_and_lead_the_tooltip() {
|
||||
let mut s = summary(false);
|
||||
assert!(!TrayStatus::Running(s.clone()).has_conflicts());
|
||||
s.conflicts = vec!["Sunshine (running)".into(), "Apollo".into()];
|
||||
let st = TrayStatus::Running(s);
|
||||
assert!(st.has_conflicts());
|
||||
let head = st.headline();
|
||||
assert!(head.starts_with("⚠ conflicting host: Sunshine (running), Apollo"));
|
||||
fn a_summary_carrying_conflicts_still_deserializes_and_is_ignored() {
|
||||
let json = r#"{"version":"0.5.1","video_streaming":false,"audio_streaming":false,
|
||||
"session":null,"paired_clients":1,"native_paired_clients":2,"pin_pending":false,
|
||||
"pending_approvals":0,"kept_displays":0,"conflicts":["Sunshine (installed)"]}"#;
|
||||
let s: Summary = serde_json::from_str(json).expect("unknown fields are ignored");
|
||||
assert_eq!(
|
||||
TrayStatus::Running(s).headline(),
|
||||
"punktfunk host 0.5.1 — idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,6 +485,32 @@ mod tests {
|
||||
.contains("status unavailable"));
|
||||
}
|
||||
|
||||
/// A live session means streaming even if the host's flag says otherwise — a host from before
|
||||
/// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a
|
||||
/// native session showed as "idle" with its own mode printed next to it.
|
||||
#[test]
|
||||
fn a_live_session_reads_as_streaming_without_the_flag() {
|
||||
let mut s = summary(true);
|
||||
s.video_streaming = false; // pre-fix host, native session
|
||||
let st = TrayStatus::Running(s);
|
||||
assert!(st.is_streaming());
|
||||
assert_eq!(
|
||||
st.headline(),
|
||||
"punktfunk host 0.5.1 — streaming 2560×1440@120"
|
||||
);
|
||||
// No session and no flag is still idle.
|
||||
assert!(!TrayStatus::Running(summary(false)).is_streaming());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_displays_are_reported_for_the_release_action() {
|
||||
assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0);
|
||||
let mut s = summary(false);
|
||||
s.kept_displays = 2;
|
||||
assert_eq!(TrayStatus::Running(s).kept_displays(), 2);
|
||||
assert_eq!(TrayStatus::Degraded.kept_displays(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_attention_flags() {
|
||||
let mut s = summary(false);
|
||||
|
||||
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
|
||||
const IDM_LOGS: usize = 0x0105;
|
||||
const IDM_EXIT: usize = 0x0106;
|
||||
const IDM_PAIRING: usize = 0x0107;
|
||||
const IDM_DISPLAYS: usize = 0x0108;
|
||||
|
||||
/// Icon resource ordinals (embedded by build.rs).
|
||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||
@@ -73,8 +74,9 @@ struct App {
|
||||
taskbar_created: u32,
|
||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||
host_exe: Option<std::path::PathBuf>,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe. Drives the label of the (always
|
||||
/// present) "Open web console" entry, and whether a left-click on the icon opens the console
|
||||
/// or falls back to showing the menu.
|
||||
web_console: AtomicBool,
|
||||
web_port: u16,
|
||||
}
|
||||
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
|
||||
};
|
||||
add(IDM_HEADER, &status.headline(), true);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
// The console entry is ALWAYS here — it is the reason most people open this menu, and
|
||||
// left-clicking the icon is not a discoverable substitute. When the loopback probe says
|
||||
// the console isn't answering the label says so, rather than the entry vanishing.
|
||||
if app().web_console.load(Ordering::SeqCst) {
|
||||
add(IDM_OPEN_WEB, "Open web console", false);
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
} else {
|
||||
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||
}
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
}
|
||||
match status.kept_displays() {
|
||||
0 => {}
|
||||
1 => add(IDM_DISPLAYS, "Release kept display…", false),
|
||||
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
if can_control {
|
||||
if startable {
|
||||
add(IDM_START, "Start host", false);
|
||||
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_web_console(hwnd: HWND) {
|
||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||
/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
|
||||
fn open_web_console(hwnd: HWND, path: &str) {
|
||||
shell_open(
|
||||
hwnd,
|
||||
&format!("https://localhost:{}/{path}", app().web_port),
|
||||
);
|
||||
}
|
||||
|
||||
fn open_logs(hwnd: HWND) {
|
||||
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
WM_CONTEXTMENU => show_menu(hwnd),
|
||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||
if app.web_console.load(Ordering::SeqCst) {
|
||||
open_web_console(hwnd);
|
||||
open_web_console(hwnd, "");
|
||||
} else {
|
||||
show_menu(hwnd);
|
||||
}
|
||||
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
match (wparam.0) & 0xffff {
|
||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
||||
IDM_PAIRING => open_web_console(hwnd),
|
||||
IDM_OPEN_WEB => open_web_console(hwnd, ""),
|
||||
IDM_PAIRING => open_web_console(hwnd, "pairing"),
|
||||
IDM_DISPLAYS => open_web_console(hwnd, "displays"),
|
||||
IDM_START => elevate_service(hwnd, "start"),
|
||||
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
NT-handle import: consume on SUCCESS only — PUNKTFUNK LOCAL PATCH.
|
||||
|
||||
Not upstream. Granite's DeviceAllocator::internal_allocate closed the imported
|
||||
by-reference NT handle (OPAQUE_WIN32 / D3D11_TEXTURE / D3D12_RESOURCE — the
|
||||
predicate excludes fd and KMT types) UNCONDITIONALLY after the FIRST
|
||||
vkAllocateMemory — on success AND on failure — while every failure before the
|
||||
allocator (c-shim validation, vkCreateImage, find_memory_type) left the handle
|
||||
open. Both classes surface to pyrowave_image_create's caller as the same error
|
||||
code, so the caller could not know whether to close: punktfunk's import_plane
|
||||
closed on every failure and double-closed whenever the failure was at or after
|
||||
the allocate — and a recycled handle value makes that close an unrelated live
|
||||
handle's. The unconditional consume also broke the allocator's own
|
||||
block-recycling retry loop, which re-ran vkAllocateMemory with import_info
|
||||
still pointing at the just-closed handle.
|
||||
|
||||
Fixed by moving the consume to the API commit point: pyrowave_image_create
|
||||
closes the by-reference handle exactly on its success return. This pins
|
||||
pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||
import") to mean on SUCCESS, matching pyrowave_sync_object_create (Granite's
|
||||
semaphore import already closes only after a successful vkImportSemaphore*).
|
||||
NT-handle memory imports never transfer ownership at the Vulkan level (the
|
||||
implementation holds its own reference), so the post-create close is legal.
|
||||
|
||||
Behavior note: Granite code that imports by-reference memory WITHOUT going
|
||||
through pyrowave_image_create — in this vendored subset only wsi_dxgi.cpp's
|
||||
interop swapchain, which the pyrowave C API never reaches — now keeps the
|
||||
caller's handle open on allocate-stage failure instead of consuming it. That
|
||||
matches the semaphore contract; those callers own their close-on-failure.
|
||||
|
||||
Correctness re-validated by pyrowave_win_smoke on real hardware after the
|
||||
change.
|
||||
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||
index f33bdac3..fd035b66 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/Granite/vulkan/memory_allocator.cpp
|
||||
@@ -732,16 +732,15 @@ bool DeviceAllocator::internal_allocate(
|
||||
res = table->vkAllocateMemory(device->get_device(), &info, nullptr, &device_memory);
|
||||
}
|
||||
|
||||
- // If we're importing, make sure we consume the native handle.
|
||||
- if (external && bool(*external) &&
|
||||
- ExternalHandle::memory_handle_type_imports_by_reference(external->memory_handle_type))
|
||||
- {
|
||||
-#ifdef _WIN32
|
||||
- ::CloseHandle(external->handle);
|
||||
-#else
|
||||
- ::close(external->handle);
|
||||
-#endif
|
||||
- }
|
||||
+ // PUNKTFUNK (patch 0006): the consume of by-reference native handles used to happen HERE,
|
||||
+ // unconditionally — success AND failure of the first vkAllocateMemory. That made the caller's
|
||||
+ // failure contract ambiguous (an allocate-stage failure had already closed the handle while a
|
||||
+ // create/find_memory_type failure had not), and the block-recycling retry loop below re-ran
|
||||
+ // vkAllocateMemory with import_info still pointing at the just-closed handle. The consume now
|
||||
+ // lives at the API commit point (pyrowave_c.cpp: pyrowave_image_create's success return), so
|
||||
+ // the allocator never closes a caller's handle and retries import a still-open handle.
|
||||
+ // (fd-type imports were never affected: memory_handle_type_imports_by_reference excludes
|
||||
+ // OPAQUE_FD/DMA_BUF, whose ownership vkAllocateMemory itself transfers on success.)
|
||||
|
||||
if (res == VK_SUCCESS)
|
||||
{
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
index eb917e64..985cd0a9 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
|
||||
@@ -592,6 +592,21 @@ pyrowave_result pyrowave_image_create(const pyrowave_image_create_info *info, py
|
||||
if (!img)
|
||||
return PYROWAVE_ERROR_FAILED_EXTERNAL_HANDLE;
|
||||
|
||||
+ // PUNKTFUNK (patch 0006): consume the imported by-reference NT handle exactly here, at the
|
||||
+ // API's success boundary — the allocator no longer closes it (see memory_allocator.cpp).
|
||||
+ // This pins pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||
+ // import") to mean ON SUCCESS, matching pyrowave_sync_object_create's semantics: on ANY
|
||||
+ // failure return the caller still owns the handle and can close it unconditionally.
|
||||
+ // By-reference NT-handle imports never transfer ownership at the Vulkan level (the
|
||||
+ // implementation keeps its own reference), so a post-create close here is always legal.
|
||||
+#ifdef _WIN32
|
||||
+ if (info->external_handle &&
|
||||
+ ExternalHandle::memory_handle_type_imports_by_reference(info->handle_type))
|
||||
+ {
|
||||
+ ::CloseHandle(reinterpret_cast<HANDLE>(info->external_handle));
|
||||
+ }
|
||||
+#endif
|
||||
+
|
||||
auto *image = new pyrowave_image_opaque();
|
||||
image->device = &device;
|
||||
image->img = std::move(img);
|
||||
@@ -732,16 +732,15 @@ bool DeviceAllocator::internal_allocate(
|
||||
res = table->vkAllocateMemory(device->get_device(), &info, nullptr, &device_memory);
|
||||
}
|
||||
|
||||
// If we're importing, make sure we consume the native handle.
|
||||
if (external && bool(*external) &&
|
||||
ExternalHandle::memory_handle_type_imports_by_reference(external->memory_handle_type))
|
||||
{
|
||||
#ifdef _WIN32
|
||||
::CloseHandle(external->handle);
|
||||
#else
|
||||
::close(external->handle);
|
||||
#endif
|
||||
}
|
||||
// PUNKTFUNK (patch 0006): the consume of by-reference native handles used to happen HERE,
|
||||
// unconditionally — success AND failure of the first vkAllocateMemory. That made the caller's
|
||||
// failure contract ambiguous (an allocate-stage failure had already closed the handle while a
|
||||
// create/find_memory_type failure had not), and the block-recycling retry loop below re-ran
|
||||
// vkAllocateMemory with import_info still pointing at the just-closed handle. The consume now
|
||||
// lives at the API commit point (pyrowave_c.cpp: pyrowave_image_create's success return), so
|
||||
// the allocator never closes a caller's handle and retries import a still-open handle.
|
||||
// (fd-type imports were never affected: memory_handle_type_imports_by_reference excludes
|
||||
// OPAQUE_FD/DMA_BUF, whose ownership vkAllocateMemory itself transfers on success.)
|
||||
|
||||
if (res == VK_SUCCESS)
|
||||
{
|
||||
|
||||
@@ -592,6 +592,21 @@ pyrowave_result pyrowave_image_create(const pyrowave_image_create_info *info, py
|
||||
if (!img)
|
||||
return PYROWAVE_ERROR_FAILED_EXTERNAL_HANDLE;
|
||||
|
||||
// PUNKTFUNK (patch 0006): consume the imported by-reference NT handle exactly here, at the
|
||||
// API's success boundary — the allocator no longer closes it (see memory_allocator.cpp).
|
||||
// This pins pyrowave.h's documented contract ("take ownership and close the HANDLE on
|
||||
// import") to mean ON SUCCESS, matching pyrowave_sync_object_create's semantics: on ANY
|
||||
// failure return the caller still owns the handle and can close it unconditionally.
|
||||
// By-reference NT-handle imports never transfer ownership at the Vulkan level (the
|
||||
// implementation keeps its own reference), so a post-create close here is always legal.
|
||||
#ifdef _WIN32
|
||||
if (info->external_handle &&
|
||||
ExternalHandle::memory_handle_type_imports_by_reference(info->handle_type))
|
||||
{
|
||||
::CloseHandle(reinterpret_cast<HANDLE>(info->external_handle));
|
||||
}
|
||||
#endif
|
||||
|
||||
auto *image = new pyrowave_image_opaque();
|
||||
image->device = &device;
|
||||
image->img = std::move(img);
|
||||
|
||||
@@ -85,11 +85,10 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
||||
|
||||
The template is deliberately minimal — it does **not** force a compositor, because the host
|
||||
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
||||
mid-stream. The only settings that matter are the session anchors (GPU zero-copy is on by default):
|
||||
mid-stream. No session anchors are needed either (a user service inherits the right runtime dir).
|
||||
The only settings that matter (GPU zero-copy is on by default):
|
||||
|
||||
```sh
|
||||
XDG_RUNTIME_DIR=/run/user/1000
|
||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
||||
@@ -99,12 +98,14 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session
|
||||
|
||||
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
|
||||
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session,
|
||||
the host attaches to whatever's live and never tears it down, and the streamed game-mode resolution
|
||||
is the box's own gamescope mode. Switching Desktop ↔ Game is rock-solid.
|
||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host launches its
|
||||
**own** gamescope at the *client's* exact resolution and refresh. Client-mode-following, but there
|
||||
must be no physical gaming session already running.
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
|
||||
gamescope session on its own display, and the host attaches to whatever's live without ever
|
||||
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
|
||||
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
|
||||
Desktop ↔ Game is rock-solid.
|
||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
|
||||
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
|
||||
refresh — Game Mode on the virtual screen — restoring the box on idle.
|
||||
|
||||
Full treatment: [Steam / gamescope → Attach vs managed](/docs/gamescope#attach-vs-managed).
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user