Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12df1388de | ||
|
|
7295ae70f9 |
+3
-6
@@ -1,12 +1,9 @@
|
||||
# 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.
|
||||
# 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.
|
||||
*
|
||||
!web
|
||||
!api/openapi.json
|
||||
!rust-toolchain.toml
|
||||
!ci/pf-host-cc
|
||||
web/node_modules
|
||||
web/.output
|
||||
web/dist
|
||||
|
||||
@@ -46,23 +46,13 @@ jobs:
|
||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
|
||||
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
|
||||
# That download was the single flakiest piece of this job: the shared runner fleet drops
|
||||
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
|
||||
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
|
||||
packages: platform-tools
|
||||
|
||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# auto-download it if needed during the build.
|
||||
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
|
||||
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
|
||||
# attempt leaves no partial package behind, so a plain re-invoke is safe.
|
||||
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
|
||||
- name: Caches (cargo + gradle)
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Announce a stable release to the Discord #releases channel.
|
||||
#
|
||||
# This is the deliberate "go" step for a release. Release notes live in the repo at
|
||||
# docs/releases/<tag>.md and are seeded into the Gitea release body at creation by the build
|
||||
# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every
|
||||
# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes
|
||||
# file over the live release and posts a formatted embed to #releases.
|
||||
#
|
||||
# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are
|
||||
# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release
|
||||
# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true.
|
||||
#
|
||||
# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth
|
||||
# reuses REGISTRY_TOKEN like the other release workflows.
|
||||
name: announce
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to announce (e.g. v0.18.0)"
|
||||
required: true
|
||||
allow_prerelease:
|
||||
description: "Announce even if the tag is a pre-release (-rc)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
@@ -61,43 +61,6 @@ 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
|
||||
|
||||
@@ -108,53 +71,6 @@ 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,13 +1,9 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both 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
|
||||
@@ -273,94 +269,3 @@ 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
|
||||
|
||||
@@ -99,41 +99,3 @@ jobs:
|
||||
mkdir -p ~/unom-flatpak/site/repo
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync winget source compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
|
||||
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
|
||||
target: "~/unom-winget"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start winget REST source
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
|
||||
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
|
||||
# source exists so the container starts; it serves 503 until the first catalogue lands.
|
||||
mkdir -p ~/unom-winget/data
|
||||
cd ~/unom-winget
|
||||
docker compose -f compose.production.yml up -d
|
||||
# Surface a missing catalogue here rather than letting winget report "no package found".
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# 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").
|
||||
#
|
||||
@@ -81,41 +80,6 @@ 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
|
||||
|
||||
@@ -73,20 +73,33 @@ jobs:
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve)
|
||||
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
||||
run: |
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
|
||||
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
|
||||
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
|
||||
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
|
||||
# local dnsmasq cache on the docker bridge and daemon.json points every job container
|
||||
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
|
||||
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
|
||||
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
|
||||
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
|
||||
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
|
||||
# genuine upstream blips.
|
||||
# Resolve over TCP instead of UDP. The documented root cause of the flathub
|
||||
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
|
||||
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
|
||||
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
|
||||
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
|
||||
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
|
||||
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
|
||||
# each needing a manual re-run.
|
||||
#
|
||||
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
|
||||
# silently lost under load. Same resolver, same search path — only the transport
|
||||
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
|
||||
# NO extra nameservers, which would risk answering an internal name from a public
|
||||
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
|
||||
# retry.sh stays as the backstop for genuine upstream blips.
|
||||
#
|
||||
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
|
||||
# DNS tuning that cannot be applied must not be what fails the release build — that
|
||||
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
|
||||
# today's behaviour, which retry.sh already covers.
|
||||
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
|
||||
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|
||||
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
|
||||
fi
|
||||
cat /etc/resolv.conf || true
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
|
||||
@@ -39,25 +39,6 @@ jobs:
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
|
||||
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
|
||||
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
|
||||
# this step once bun links `file:` deps correctly again.
|
||||
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
# -f follows the link, so this is true only when the manifest actually resolves.
|
||||
if test -f node_modules/@punktfunk/host/package.json; then
|
||||
echo "bun linked it correctly — this step can go"
|
||||
else
|
||||
rm -rf node_modules/@punktfunk/host
|
||||
cp -R ../sdk node_modules/@punktfunk/host
|
||||
fi
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -393,76 +393,6 @@ jobs:
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — export .ipa (Gitea release + run artifact)
|
||||
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
|
||||
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
|
||||
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
|
||||
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
|
||||
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
|
||||
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
id: ios_ipa
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
|
||||
if [ ! -d "$ARCHIVE" ]; then
|
||||
echo "::warning::iOS archive not found — skipping .ipa export"
|
||||
exit 0
|
||||
fi
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
|
||||
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
|
||||
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>export</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-ipa"
|
||||
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
|
||||
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
|
||||
mv "$SRC" "$IPA"
|
||||
echo "IPA=$IPA" >> "$GITHUB_ENV"
|
||||
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
|
||||
echo "exported $IPA"
|
||||
|
||||
- name: Attach .ipa to the workflow run
|
||||
if: steps.ios_ipa.outputs.ipa != ''
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-ios-ipa
|
||||
path: ${{ steps.ios_ipa.outputs.ipa }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Attach .ipa to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
|
||||
@@ -50,23 +50,6 @@ 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/**'
|
||||
@@ -171,58 +154,10 @@ 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 and pf-capture are linted SEPARATELY with --all-targets so their Windows
|
||||
# `#[cfg(test)]` modules are type-checked — pf-encode's 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, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
|
||||
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
|
||||
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
|
||||
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
|
||||
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
|
||||
# feature juggling and pulls in no extra dep tree.
|
||||
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
|
||||
# vehicle — `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.
|
||||
#
|
||||
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
|
||||
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
|
||||
# links against nothing this runner lacks, and it reuses the release artifacts the steps
|
||||
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
|
||||
# truth table, the IDD generation masking — are Windows-only code that NO other job can
|
||||
# execute, so linting them was leaving real coverage on the table. See the run step below.
|
||||
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 pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Test (pf-capture, Windows)
|
||||
shell: pwsh
|
||||
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
|
||||
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
|
||||
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
|
||||
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
|
||||
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
|
||||
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
|
||||
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
|
||||
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
|
||||
# the whole set was type-checked by the clippy line above and nothing more.
|
||||
#
|
||||
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
|
||||
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
|
||||
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
|
||||
#
|
||||
# The link question this step turns on was settled empirically before it was added: the same
|
||||
# command was run on a Windows dev box against a workspace checkout and linked + executed
|
||||
# cleanly, building in ~51 s off an existing release target dir.
|
||||
run: |
|
||||
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||
@@ -359,82 +294,3 @@ jobs:
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
# winget manifests for the release just attached above. Runs AFTER the attach step so the
|
||||
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
|
||||
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
|
||||
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
|
||||
# alias has nothing it could point at.
|
||||
- name: Emit + attach winget manifests (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
& scripts/ci/winget-manifest.ps1 `
|
||||
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
|
||||
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
|
||||
}
|
||||
|
||||
# Republish the winget REST source on unom-1 once the release above carries its manifests.
|
||||
#
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||
- name: Build + test the source catalogue
|
||||
working-directory: packaging/winget/server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install --no-audit --no-fund
|
||||
node build-data.mjs --out data/data.json
|
||||
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
|
||||
# Gate on the suite before anything reaches the box.
|
||||
node test.mjs
|
||||
|
||||
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
|
||||
# flatpak repo's content and config deploy on separate paths.
|
||||
- name: Ship the catalogue to unom-1
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "packaging/winget/server/data/data.json"
|
||||
target: "~/unom-winget/data"
|
||||
strip_components: 4
|
||||
overwrite: true
|
||||
|
||||
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
|
||||
# one actually being served, and fails the release if it is not.
|
||||
- name: Verify the served catalogue
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
curl -fsS http://127.0.0.1:3240/healthz
|
||||
echo
|
||||
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
|
||||
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
|
||||
| grep -q "${GITHUB_REF_NAME#v}" \
|
||||
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
|
||||
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||
|
||||
Generated
+27
-59
@@ -1459,16 +1459,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2194,7 +2184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2324,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2813,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2839,12 +2829,11 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2868,7 +2857,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2955,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2981,7 +2970,6 @@ dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
@@ -2999,14 +2987,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,11 +3009,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3048,12 +3035,11 @@ dependencies = [
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3065,7 +3051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3074,7 +3060,6 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3273,7 +3258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3289,7 +3274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3305,7 +3290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3320,7 +3305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3339,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3371,7 +3356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3455,7 +3440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3469,7 +3454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3492,7 +3477,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -5901,23 +5886,6 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.17.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
|
||||
+147
-700
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.19.2"
|
||||
"version": "0.17.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -665,58 +665,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/game/end": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"session"
|
||||
],
|
||||
"summary": "End a launched game",
|
||||
"description": "Ends a game whose session has already gone and which is waiting out its reconnect window — the\nconsole's \"End now\" for a game the host is about to close anyway. `app_id` picks one title; omit it\nto end every waiting game.\n\nThis does **not** touch a game whose session is still live: ending that is session management\n(`DELETE /session`), and how the game is treated then follows the operator's policy.",
|
||||
"operationId": "endGame",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EndGameRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "How many waiting games were ended",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EndGameResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "No game is waiting to be ended",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/gpus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -965,7 +913,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -976,15 +924,6 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"in": "query",
|
||||
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2287,7 +2226,7 @@
|
||||
"session"
|
||||
],
|
||||
"summary": "Stop the active session",
|
||||
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.\n\nCounts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its\nkeep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.",
|
||||
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.",
|
||||
"operationId": "stopSession",
|
||||
"responses": {
|
||||
"204": {
|
||||
@@ -2341,98 +2280,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/session/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"session"
|
||||
],
|
||||
"summary": "Session⇄game lifetime settings",
|
||||
"description": "Whether a launched game's exit ends the streaming session, and whether a session ending ends the\ngame (with the reconnect window that protects a dropped client's unsaved progress). See\n`design/session-game-lifetime.md`.",
|
||||
"operationId": "getSessionSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored settings + which axes this build enforces",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionSettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"session"
|
||||
],
|
||||
"summary": "Set the session⇄game lifetime settings",
|
||||
"description": "Persists the settings (clamped) and applies them from the next decision — including to a session\nthat is already streaming, since the policy is read when a session ends rather than when it starts.",
|
||||
"operationId": "setSessionSettings",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings stored; the new state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionSettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed settings body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Settings could not be persisted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stats/capture/live": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3404,67 +3251,6 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ActiveGame": {
|
||||
"type": "object",
|
||||
"description": "One launched game, for the console's running-game card.",
|
||||
"required": [
|
||||
"client",
|
||||
"title",
|
||||
"plane",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command."
|
||||
},
|
||||
"client": {
|
||||
"type": "string",
|
||||
"description": "Client-supplied device name of the session that launched it; may be empty."
|
||||
},
|
||||
"grace_remaining_s": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Seconds until this game is ended — only present on a `grace` row.",
|
||||
"minimum": 0
|
||||
},
|
||||
"plane": {
|
||||
"$ref": "#/components/schemas/Plane",
|
||||
"description": "`native` or `gamestream`."
|
||||
},
|
||||
"session_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "The session streaming it; `null` for a game waiting out its reconnect window.",
|
||||
"minimum": 0
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
|
||||
"example": "running"
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiActiveGpu": {
|
||||
"type": "object",
|
||||
"description": "The GPU live sessions are encoding on right now.",
|
||||
@@ -4021,111 +3807,87 @@
|
||||
}
|
||||
},
|
||||
"CustomEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
|
||||
},
|
||||
"external_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
"type": "object",
|
||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits."
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"external_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomInput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
"type": "object",
|
||||
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomPreset": {
|
||||
"type": "object",
|
||||
@@ -4173,33 +3935,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DetectHint": {
|
||||
"type": "object",
|
||||
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||
"properties": {
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The game's own executable, as an absolute path."
|
||||
},
|
||||
"install_dir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one."
|
||||
},
|
||||
"process_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeviceRef": {
|
||||
"type": "object",
|
||||
"description": "A device in the pairing flow.",
|
||||
@@ -4391,33 +4126,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EndGameRequest": {
|
||||
"type": "object",
|
||||
"description": "Request body for `endGame`.",
|
||||
"properties": {
|
||||
"app_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Store-qualified library id (`steam:570`) to end; omit to end every waiting game."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EndGameResult": {
|
||||
"type": "object",
|
||||
"description": "Result of an `endGame`.",
|
||||
"required": [
|
||||
"ended"
|
||||
],
|
||||
"properties": {
|
||||
"ended": {
|
||||
"type": "integer",
|
||||
"description": "How many waiting games were ended.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4532,48 +4240,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher).",
|
||||
"required": [
|
||||
"game",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"game": {
|
||||
"$ref": "#/components/schemas/GameRefPayload"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"game.running"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy.",
|
||||
"required": [
|
||||
"game",
|
||||
"reason",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"game": {
|
||||
"$ref": "#/components/schemas/GameRefPayload"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"game.exited"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/components/schemas/GameEndReason"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -4766,183 +4432,49 @@
|
||||
],
|
||||
"description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \"<domain>.<verb>\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]."
|
||||
},
|
||||
"GameEndReason": {
|
||||
"type": "string",
|
||||
"description": "Why a launched game is no longer running.",
|
||||
"enum": [
|
||||
"exited",
|
||||
"terminated"
|
||||
]
|
||||
},
|
||||
"GameEntry": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"art"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
"example": "steam:570"
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec",
|
||||
"description": "How the host would launch it, when known."
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
"example": "steam"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "One title in the unified library, regardless of which store it came from."
|
||||
},
|
||||
"GameMeta": {
|
||||
"type": "object",
|
||||
"description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Short blurb for a details pane."
|
||||
},
|
||||
"developer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"genres": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)."
|
||||
},
|
||||
"platform": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).",
|
||||
"example": "PS2"
|
||||
},
|
||||
"players": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Maximum simultaneous (local) players.",
|
||||
"minimum": 0
|
||||
},
|
||||
"publisher": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"region": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)."
|
||||
},
|
||||
"release_year": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Year of first release — the granularity metadata sources reliably agree on.",
|
||||
"example": 2001,
|
||||
"minimum": 0
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameOnSessionEnd": {
|
||||
"type": "string",
|
||||
"description": "What to do with the launched game when its session ends.",
|
||||
"enum": [
|
||||
"keep",
|
||||
"on_quit",
|
||||
"always"
|
||||
]
|
||||
},
|
||||
"GameRefPayload": {
|
||||
"type": "object",
|
||||
"description": "A launched game, as the `game.*` events see it.",
|
||||
"description": "One title in the unified library, regardless of which store it came from.",
|
||||
"required": [
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"client",
|
||||
"plane"
|
||||
"art"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
"example": "steam:570"
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec",
|
||||
"description": "How the host would launch it, when known."
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it."
|
||||
},
|
||||
"client": {
|
||||
"type": "string",
|
||||
"description": "Client-supplied device name of the session that launched it; may be empty."
|
||||
},
|
||||
"plane": {
|
||||
"$ref": "#/components/schemas/Plane"
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
"example": "steam"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title."
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4974,13 +4506,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"encoder_pin": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken."
|
||||
},
|
||||
"env_override": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -5581,7 +5106,7 @@
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||
"description": "True while the audio stream thread is running."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
@@ -5590,13 +5115,6 @@
|
||||
},
|
||||
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
||||
},
|
||||
"games": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched."
|
||||
},
|
||||
"kept_displays": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -5632,7 +5150,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming."
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5642,7 +5160,7 @@
|
||||
},
|
||||
"video_streaming": {
|
||||
"type": "boolean",
|
||||
"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."
|
||||
"description": "True while the video stream thread is running."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6109,53 +5627,41 @@
|
||||
}
|
||||
},
|
||||
"ProviderEntryInput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"external_id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
|
||||
},
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
"type": "object",
|
||||
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
|
||||
"required": [
|
||||
"external_id",
|
||||
"title"
|
||||
],
|
||||
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderRemoved": {
|
||||
"type": "object",
|
||||
@@ -6219,8 +5725,7 @@
|
||||
"audio_streaming",
|
||||
"pin_pending",
|
||||
"paired_clients",
|
||||
"active_sessions",
|
||||
"games"
|
||||
"active_sessions"
|
||||
],
|
||||
"properties": {
|
||||
"active_sessions": {
|
||||
@@ -6233,13 +5738,6 @@
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
},
|
||||
"games": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ActiveGame"
|
||||
},
|
||||
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
|
||||
},
|
||||
"paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -6409,57 +5907,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionSettings": {
|
||||
"type": "object",
|
||||
"description": "The persisted settings.",
|
||||
"properties": {
|
||||
"disconnect_grace_seconds": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
|
||||
"minimum": 0
|
||||
},
|
||||
"game_on_session_end": {
|
||||
"$ref": "#/components/schemas/GameOnSessionEnd",
|
||||
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
|
||||
},
|
||||
"session_on_game_exit": {
|
||||
"type": "boolean",
|
||||
"description": "End the streaming session when the launched game exits."
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionSettingsState": {
|
||||
"type": "object",
|
||||
"description": "The session⇄game lifetime settings, plus which axes this build acts on.",
|
||||
"required": [
|
||||
"settings",
|
||||
"configured",
|
||||
"enforced"
|
||||
],
|
||||
"properties": {
|
||||
"configured": {
|
||||
"type": "boolean",
|
||||
"description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)."
|
||||
},
|
||||
"enforced": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Which fields this build actually enforces. Empty on a platform with no launch path (macOS),\nso the console can say so instead of offering a switch that does nothing."
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/SessionSettings",
|
||||
"description": "The stored settings (or the built-in defaults when this host has never been configured)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetGpuPreference": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setGpuPreference`.",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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[@]}"
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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
|
||||
@@ -1,107 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
/**
|
||||
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
|
||||
* * **Device → host**: a local copy (the primary-clip listener, plus one probe at start) is
|
||||
* announced as a lazy offer — the text crosses only when the host actually pastes (a
|
||||
* `fetch:` event, answered with the clipboard's current content).
|
||||
* * **Host → device**: a host copy arrives as an `offer:` event and is fetched eagerly into
|
||||
* the system clipboard (Android apps can't lazily materialize a paste from the network
|
||||
* without a content-provider round-trip that isn't worth it here).
|
||||
*
|
||||
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
|
||||
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
|
||||
* happen while the stream is foreground (Android only allows focused-app reads). The native
|
||||
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
|
||||
*/
|
||||
class ClipboardSync(
|
||||
private val context: Context,
|
||||
private val handle: Long,
|
||||
) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
||||
@Volatile private var running = true
|
||||
private var seq = 0
|
||||
private var lastOffered: String? = null
|
||||
private var lastFromHost: String? = null
|
||||
private var pendingFetch = -1
|
||||
private var thread: Thread? = null
|
||||
|
||||
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
|
||||
|
||||
fun start() {
|
||||
NativeBridge.nativeClipControl(handle, true)
|
||||
cm.addPrimaryClipChangedListener(clipListener)
|
||||
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
|
||||
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
cm.removePrimaryClipChangedListener(clipListener)
|
||||
thread?.join(600) // one poll timeout (250 ms) + slack
|
||||
thread = null
|
||||
}
|
||||
|
||||
/** Announce the current local text (if it's new and not an echo of a host copy). */
|
||||
private fun offerLocal() {
|
||||
if (!running) return
|
||||
val text = currentClipText() ?: return
|
||||
if (text == lastOffered || text == lastFromHost) return
|
||||
lastOffered = text
|
||||
seq += 1
|
||||
NativeBridge.nativeClipOfferText(handle, seq)
|
||||
}
|
||||
|
||||
private fun currentClipText(): String? = runCatching {
|
||||
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
|
||||
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun pollLoop() {
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextClip(handle) ?: continue
|
||||
if (ev == "closed") return
|
||||
main.post { handleEvent(ev) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(ev: String) {
|
||||
if (!running) return
|
||||
val parts = ev.split(":", limit = 3)
|
||||
when (parts[0]) {
|
||||
"offer" -> {
|
||||
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
if (parts.getOrNull(2) == "1") {
|
||||
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
|
||||
}
|
||||
}
|
||||
"fetch" -> {
|
||||
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
val text = currentClipText()
|
||||
if (text != null) {
|
||||
NativeBridge.nativeClipServeText(handle, req, text)
|
||||
} else {
|
||||
NativeBridge.nativeClipCancel(handle, req)
|
||||
}
|
||||
}
|
||||
"data" -> {
|
||||
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
if (xfer != pendingFetch) return // stale/unknown transfer
|
||||
pendingFetch = -1
|
||||
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
|
||||
lastFromHost = text
|
||||
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
|
||||
}
|
||||
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,21 +54,6 @@ class MainActivity : ComponentActivity() {
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
||||
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
|
||||
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
|
||||
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
|
||||
*/
|
||||
var mouseForwarder: MouseForwarder? = null
|
||||
|
||||
/**
|
||||
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
|
||||
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
|
||||
* non-gamepad keys while streaming. Null while not streaming or not a TV.
|
||||
*/
|
||||
var remotePointer: RemotePointer? = null
|
||||
|
||||
/**
|
||||
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
|
||||
* couch user with no keyboard/Back can always leave a stream.
|
||||
@@ -339,37 +324,9 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
remotePointer?.let { if (it.onKey(event)) return true }
|
||||
}
|
||||
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
|
||||
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
|
||||
if (event.keyCode == KeyEvent.KEYCODE_Q &&
|
||||
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
|
||||
) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
mouseForwarder?.toggleCapture()
|
||||
}
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// A mouse's back/forward buttons already go over the wire as X1/X2 via their
|
||||
// BUTTON_* motion edges — but Android ALSO delivers them as key events: the input
|
||||
// reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally,
|
||||
// and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed.
|
||||
// Swallow every such duplicate or it doubles as Android navigation and yanks the
|
||||
// user out of the stream. A remote/keyboard BACK is never mouse-sourced, so it
|
||||
// still falls through to the BackHandler and exits.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Leave these to the system even while streaming.
|
||||
// (BACK above → BackHandler leaves the stream.)
|
||||
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
|
||||
KeyEvent.KEYCODE_VOLUME_UP,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
@@ -437,10 +394,6 @@ class MainActivity : ComponentActivity() {
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L) {
|
||||
if (gamepadRouter?.onMotion(event) == true) return true
|
||||
// Physical mouse (uncaptured): hover motion, wheel, button edges.
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
||||
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||
@@ -478,24 +431,6 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
|
||||
* belong to the mouse forwarder, never to the Compose touch-gesture layer — a physical
|
||||
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
|
||||
*/
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
|
||||
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
|
||||
override fun onPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.onPointerCaptureChanged(hasCapture)
|
||||
mouseForwarder?.onCaptureChanged(hasCapture)
|
||||
}
|
||||
|
||||
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
|
||||
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
|
||||
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical mouse → wire, in two modes (the iPadOS/desktop model):
|
||||
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
|
||||
* (`MouseMoveAbs`, host-normalized against the window size) — desktop-style pointing. The
|
||||
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
|
||||
* host's own cursor, composited into the video, is the one you see.
|
||||
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
|
||||
* relative deltas forward as `MouseMove` — FPS mouse-look. Engaged at stream start / by
|
||||
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
|
||||
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
|
||||
* guarantees that); a click re-engages.
|
||||
*
|
||||
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward →
|
||||
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
|
||||
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
|
||||
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
|
||||
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
|
||||
*/
|
||||
class MouseForwarder(
|
||||
private val handle: Long,
|
||||
private val invertScroll: Boolean,
|
||||
private val captureWanted: Boolean,
|
||||
private val surfaceSize: () -> Pair<Int, Int>,
|
||||
) {
|
||||
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
|
||||
var onRequestCapture: (() -> Unit)? = null
|
||||
var onReleaseCapture: (() -> Unit)? = null
|
||||
|
||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
||||
var captured = false
|
||||
private set
|
||||
|
||||
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
|
||||
private var userReleased = false
|
||||
|
||||
private val heldButtons = mutableSetOf<Int>()
|
||||
private var scrollAccV = 0f
|
||||
private var scrollAccH = 0f
|
||||
private var moveAccX = 0f
|
||||
private var moveAccY = 0f
|
||||
|
||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (captureWanted && !captured && !userReleased) {
|
||||
// The engaging click: grab the pointer and swallow the click (desktop
|
||||
// parity — the click that captures never reaches the host). The paired
|
||||
// BUTTON_RELEASE is dropped by the held-set guard in [button].
|
||||
onRequestCapture?.invoke()
|
||||
return true
|
||||
}
|
||||
sendAbs(ev)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> sendAbs(ev)
|
||||
// Button edges are documented on the generic stream, but be robust to either.
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
|
||||
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
|
||||
* captured touchpad reports absolute finger coordinates instead — not handled (the touch
|
||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
||||
*/
|
||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = 0f
|
||||
var dy = 0f
|
||||
for (i in 0 until ev.historySize) {
|
||||
dx += ev.getHistoricalX(i)
|
||||
dy += ev.getHistoricalY(i)
|
||||
}
|
||||
dx += ev.x
|
||||
dy += ev.y
|
||||
moveAccX += dx
|
||||
moveAccY += dy
|
||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
|
||||
val oy = moveAccY.toInt()
|
||||
if (ox != 0 || oy != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
||||
moveAccX -= ox
|
||||
moveAccY -= oy
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
|
||||
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
|
||||
fun toggleCapture() {
|
||||
if (captured) {
|
||||
userReleased = true
|
||||
onReleaseCapture?.invoke()
|
||||
} else {
|
||||
userReleased = false
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
||||
fun engageFromStart() {
|
||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
|
||||
fun onCaptureChanged(has: Boolean) {
|
||||
captured = has
|
||||
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
|
||||
if (!has) flushButtons()
|
||||
}
|
||||
|
||||
/** Stream teardown: lift anything held and let the grab go. */
|
||||
fun release() {
|
||||
flushButtons()
|
||||
if (captured) onReleaseCapture?.invoke()
|
||||
}
|
||||
|
||||
private fun sendAbs(ev: MotionEvent) {
|
||||
val (w, h) = surfaceSize()
|
||||
if (w <= 0 || h <= 0) return
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
ev.x.roundToInt().coerceIn(0, w - 1),
|
||||
ev.y.roundToInt().coerceIn(0, h - 1),
|
||||
w,
|
||||
h,
|
||||
)
|
||||
}
|
||||
|
||||
private fun wheel(ev: MotionEvent) {
|
||||
val dir = if (invertScroll) -1f else 1f
|
||||
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
|
||||
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
|
||||
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
|
||||
val v = scrollAccV.toInt()
|
||||
if (v != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, v)
|
||||
scrollAccV -= v
|
||||
}
|
||||
val h = scrollAccH.toInt()
|
||||
if (h != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, h)
|
||||
scrollAccH -= h
|
||||
}
|
||||
}
|
||||
|
||||
private fun button(actionButton: Int, down: Boolean) {
|
||||
val b = when (actionButton) {
|
||||
MotionEvent.BUTTON_PRIMARY -> 1
|
||||
MotionEvent.BUTTON_TERTIARY -> 2
|
||||
MotionEvent.BUTTON_SECONDARY -> 3
|
||||
MotionEvent.BUTTON_BACK -> 4
|
||||
MotionEvent.BUTTON_FORWARD -> 5
|
||||
else -> return
|
||||
}
|
||||
if (down) {
|
||||
heldButtons.add(b)
|
||||
NativeBridge.nativeSendPointerButton(handle, b, true)
|
||||
} else if (heldButtons.remove(b)) {
|
||||
// Only release what we pressed — drops the release of a swallowed engaging click
|
||||
// and anything that raced a capture transition.
|
||||
NativeBridge.nativeSendPointerButton(handle, b, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushButtons() {
|
||||
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
|
||||
heldButtons.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
import android.view.KeyEvent
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.hypot
|
||||
|
||||
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
|
||||
// action instead of the tap action.
|
||||
private const val LONG_PRESS_MS = 800L
|
||||
|
||||
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
|
||||
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
|
||||
private const val SPEED_MIN = 0.14f
|
||||
private const val SPEED_MAX = 0.70f
|
||||
private const val RAMP_S = 1.2f
|
||||
|
||||
/**
|
||||
* Android TV remote as a pointer — the Android analogue of the Apple client's Siri-remote pointer,
|
||||
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
|
||||
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
|
||||
*
|
||||
* While streaming on a TV, **hold SELECT ≈ 0.8 s** to toggle pointer mode. While active:
|
||||
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
|
||||
* Choreographer-paced, diagonal-normalized);
|
||||
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
|
||||
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
|
||||
* (a second BACK then leaves the stream as usual).
|
||||
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
|
||||
* arrow keys, SELECT tap = Enter — synthesized on release, since the down was held back to
|
||||
* disambiguate the long-press).
|
||||
*
|
||||
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
|
||||
* state lives on the main thread.
|
||||
*/
|
||||
class RemotePointer(
|
||||
private val handle: Long,
|
||||
private val surfaceWidth: () -> Int,
|
||||
private val onActiveChanged: (Boolean) -> Unit,
|
||||
private val onKeyboardToggle: () -> Unit,
|
||||
) {
|
||||
var active = false
|
||||
private set
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
||||
private var moveAccX = 0f
|
||||
private var moveAccY = 0f
|
||||
private var lastFrameNs = 0L
|
||||
private var rampSec = 0f
|
||||
private var tickerRunning = false
|
||||
private var centerLongFired = false
|
||||
private var playLongFired = false
|
||||
|
||||
private val centerLong = Runnable {
|
||||
centerLongFired = true
|
||||
toggle()
|
||||
}
|
||||
private val playLong = Runnable {
|
||||
playLongFired = true
|
||||
onKeyboardToggle()
|
||||
}
|
||||
|
||||
private val frame = object : Choreographer.FrameCallback {
|
||||
override fun doFrame(nowNs: Long) {
|
||||
if (!tickerRunning) return
|
||||
if (held.isEmpty() || !active) {
|
||||
tickerRunning = false
|
||||
return
|
||||
}
|
||||
val dt = if (lastFrameNs == 0L) {
|
||||
1f / 60f
|
||||
} else {
|
||||
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
|
||||
}
|
||||
lastFrameNs = nowNs
|
||||
rampSec += dt
|
||||
var vx = 0f
|
||||
var vy = 0f
|
||||
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
|
||||
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
|
||||
val mag = hypot(vx, vy)
|
||||
if (mag > 0f) {
|
||||
val w = surfaceWidth().coerceAtLeast(640)
|
||||
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
|
||||
moveAccX += vx / mag * speed * dt
|
||||
moveAccY += vy / mag * speed * dt
|
||||
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
|
||||
val oy = moveAccY.toInt()
|
||||
if (ox != 0 || oy != 0) {
|
||||
NativeBridge.nativeSendPointerMove(handle, ox, oy)
|
||||
moveAccX -= ox
|
||||
moveAccY -= oy
|
||||
}
|
||||
}
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
|
||||
fun onKey(event: KeyEvent): Boolean {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
if (down) {
|
||||
if (event.repeatCount == 0) {
|
||||
centerLongFired = false
|
||||
handler.postDelayed(centerLong, LONG_PRESS_MS)
|
||||
}
|
||||
} else {
|
||||
handler.removeCallbacks(centerLong)
|
||||
if (!centerLongFired) {
|
||||
if (active) {
|
||||
click(1)
|
||||
} else {
|
||||
// The down was held back to disambiguate the long-press, so the
|
||||
// normal path never saw it — synthesize the Enter here instead.
|
||||
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
-> {
|
||||
if (!active) return false
|
||||
if (down) {
|
||||
if (held.add(event.keyCode) && held.size == 1) startTicker()
|
||||
} else {
|
||||
held.remove(event.keyCode)
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
if (!active) return false // inactive: the media-key VK path owns it
|
||||
if (down) {
|
||||
if (event.repeatCount == 0) {
|
||||
playLongFired = false
|
||||
handler.postDelayed(playLong, LONG_PRESS_MS)
|
||||
}
|
||||
} else {
|
||||
handler.removeCallbacks(playLong)
|
||||
if (!playLongFired) click(3)
|
||||
}
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
if (!active) return false
|
||||
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
|
||||
fun release() {
|
||||
handler.removeCallbacks(centerLong)
|
||||
handler.removeCallbacks(playLong)
|
||||
active = false
|
||||
held.clear()
|
||||
tickerRunning = false
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
active = !active
|
||||
if (!active) {
|
||||
held.clear()
|
||||
tickerRunning = false
|
||||
}
|
||||
onActiveChanged(active)
|
||||
}
|
||||
|
||||
private fun startTicker() {
|
||||
rampSec = 0f
|
||||
lastFrameNs = 0L
|
||||
if (!tickerRunning) {
|
||||
tickerRunning = true
|
||||
Choreographer.getInstance().postFrameCallback(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private fun click(button: Int) {
|
||||
NativeBridge.nativeSendPointerButton(handle, button, true)
|
||||
NativeBridge.nativeSendPointerButton(handle, button, false)
|
||||
}
|
||||
}
|
||||
@@ -109,27 +109,6 @@ data class Settings(
|
||||
* setup where the OS-level pad (lizard mode) is preferred.
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward
|
||||
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at
|
||||
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord
|
||||
* works even with this off). Off (default): a mouse points absolutely, desktop-style.
|
||||
*/
|
||||
val pointerCapture: Boolean = false,
|
||||
|
||||
/**
|
||||
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
|
||||
* the Apple/GTK clients' "Invert scroll direction".
|
||||
*/
|
||||
val invertScroll: Boolean = false,
|
||||
|
||||
/**
|
||||
* Sync text copied on this device to the host and vice versa while streaming (the desktop
|
||||
* clients' shared clipboard, text-only here). Only effective when the host advertises the
|
||||
* clipboard capability; the protocol is opt-in per session either way.
|
||||
*/
|
||||
val clipboardSync: Boolean = true,
|
||||
)
|
||||
|
||||
/** [Settings.touchMode] values; persisted by name. */
|
||||
@@ -193,9 +172,6 @@ class SettingsStore(context: Context) {
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false),
|
||||
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
|
||||
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
|
||||
)
|
||||
|
||||
fun save(s: Settings) {
|
||||
@@ -219,9 +195,6 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -260,9 +233,6 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_POINTER_CAPTURE = "pointer_capture"
|
||||
const val K_INVERT_SCROLL = "invert_scroll"
|
||||
const val K_CLIPBOARD_SYNC = "clipboard_sync"
|
||||
|
||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||
const val K_TRACKPAD = "trackpad_mode"
|
||||
|
||||
@@ -412,27 +412,6 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Capture pointer for games",
|
||||
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
|
||||
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
|
||||
"Off: the mouse points at the desktop directly",
|
||||
checked = s.pointerCapture,
|
||||
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Invert scroll direction",
|
||||
subtitle = "Flip the mouse wheel and two-finger touch scrolling",
|
||||
checked = s.invertScroll,
|
||||
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Shared clipboard",
|
||||
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
|
||||
"clipboard sharing enabled)",
|
||||
checked = s.clipboardSync,
|
||||
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
|
||||
)
|
||||
}
|
||||
SettingsCard {
|
||||
SettingDropdown(
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
@@ -50,9 +49,6 @@ 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
|
||||
@@ -180,14 +176,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
|
||||
var exitArming by remember { mutableStateOf(false) }
|
||||
|
||||
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
|
||||
var remotePointerOn by remember { mutableStateOf(false) }
|
||||
|
||||
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
|
||||
// needs a focusable view; captured-pointer events land on it). Declared before the effect
|
||||
// below so the capture callbacks can reach the view once it exists.
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -233,54 +221,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
|
||||
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
|
||||
// The local cursor is hidden over the stream — the host's own cursor, composited into
|
||||
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
|
||||
val decor = window?.decorView
|
||||
val priorPointerIcon = decor?.pointerIcon
|
||||
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
|
||||
context,
|
||||
android.view.PointerIcon.TYPE_NULL,
|
||||
)
|
||||
val mouse = MouseForwarder(
|
||||
handle,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
captureWanted = initialSettings.pointerCapture,
|
||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
||||
)
|
||||
mouse.onRequestCapture = {
|
||||
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
|
||||
// request racing view attach/focus settles on the next frame.
|
||||
keyCapture?.let { v ->
|
||||
v.post {
|
||||
v.requestFocus()
|
||||
v.requestPointerCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
|
||||
activity?.mouseForwarder = mouse
|
||||
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
|
||||
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
|
||||
val remote = if (isTv) {
|
||||
RemotePointer(
|
||||
handle,
|
||||
surfaceWidth = { decor?.width ?: 1920 },
|
||||
onActiveChanged = { on -> remotePointerOn = on },
|
||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.remotePointer = remote
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
||||
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
ClipboardSync(context, handle).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
@@ -346,7 +286,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
@@ -354,12 +293,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
|
||||
mouse.release()
|
||||
activity?.mouseForwarder = null
|
||||
remote?.release()
|
||||
activity?.remotePointer = null
|
||||
decor?.pointerIcon = priorPointerIcon
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
@@ -387,32 +320,8 @@ 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) {
|
||||
delay(400)
|
||||
activity?.mouseForwarder?.engageFromStart()
|
||||
}
|
||||
// Focus anchor the three-finger keyboard swipe summons the IME onto (see KeyCaptureView).
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
@@ -470,49 +379,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
if (exitArming) {
|
||||
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
|
||||
if (remotePointerOn) {
|
||||
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
|
||||
}
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
|
||||
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
|
||||
// touches, it just owns IME focus and receives captured-pointer events.
|
||||
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
|
||||
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
|
||||
AndroidView(
|
||||
modifier = Modifier.size(1.dp),
|
||||
factory = { ctx ->
|
||||
KeyCaptureView(ctx).also { v ->
|
||||
keyCapture = v
|
||||
// Real IME text path when the host types committed text (see KeyCaptureView).
|
||||
v.textHandle =
|
||||
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
|
||||
v.setOnCapturedPointerListener { _, ev ->
|
||||
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
|
||||
}
|
||||
}
|
||||
},
|
||||
factory = { ctx -> KeyCaptureView(ctx).also { keyCapture = it } },
|
||||
)
|
||||
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
|
||||
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
|
||||
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
|
||||
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
|
||||
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
|
||||
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
|
||||
// stationary held stroke alive (and its cancellation lifts everything on teardown).
|
||||
val stylus = remember(handle) {
|
||||
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
}
|
||||
if (stylus != null) {
|
||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
stylus,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
)
|
||||
@@ -540,35 +423,14 @@ private fun ExitChordHint(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
|
||||
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemotePointerHint(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
|
||||
* onto this view. Two IME models, picked by the host's capabilities:
|
||||
* * **Text path** ([textHandle] set — the host advertised `HOST_CAP_TEXT_INPUT`): a real
|
||||
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
|
||||
* composition and emoji, all mirrored to the host as committed text + diffs.
|
||||
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode — raw
|
||||
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` → `Keymap.toVk` → the host, the
|
||||
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
|
||||
*
|
||||
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
|
||||
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
|
||||
* stream screen installs).
|
||||
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
|
||||
* (no composing text, no autocorrect), which flow through `MainActivity.dispatchKeyEvent` →
|
||||
* `Keymap.toVk` → the host, the exact path a hardware keyboard takes. Text an IME insists on
|
||||
* committing instead still arrives: the non-editable [BaseInputConnection] synthesizes KeyEvents
|
||||
* for it via `KeyCharacterMap` (with Shift carried as meta state — see the IME-shift wrap in
|
||||
* `MainActivity.dispatchKeyEvent`).
|
||||
*/
|
||||
private class KeyCaptureView(context: Context) : View(context) {
|
||||
init {
|
||||
@@ -576,171 +438,22 @@ private class KeyCaptureView(context: Context) : View(context) {
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
/** The session handle when the host types committed text; `0` = VK-only fallback. */
|
||||
var textHandle: Long = 0L
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
|
||||
var imeShown = false
|
||||
private set
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = imeShown
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
// Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle).
|
||||
// This view holds focus for the whole stream (it's the capture anchor), and with an
|
||||
// always-live editable connection the IME counts input as active on it — TV IMEs then
|
||||
// pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware
|
||||
// typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears.
|
||||
if (!imeShown) return null
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
|
||||
return if (textHandle != 0L) {
|
||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
|
||||
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||
HostTextConnection(this, textHandle)
|
||||
} else {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
BaseInputConnection(this, false)
|
||||
}
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||
outAttrs.inputType = InputType.TYPE_NULL
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||
return BaseInputConnection(this, false)
|
||||
}
|
||||
|
||||
fun setImeVisible(show: Boolean) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
?: return
|
||||
imeShown = show
|
||||
if (show) {
|
||||
requestFocus()
|
||||
// The view may already be focused from a null-connection state — restart so the
|
||||
// framework re-queries onCreateInputConnection with the gate now open.
|
||||
imm.restartInput(this)
|
||||
imm.showSoftInput(this, 0)
|
||||
} else {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
imm.restartInput(this) // gate closed — drop the editable connection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so
|
||||
* [setImeVisible] never hears about it — sync the gate here or a stale `imeShown` leaves the
|
||||
* editable connection live and physical typing re-pops the keyboard.
|
||||
*/
|
||||
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) {
|
||||
imeShown = false
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
|
||||
?.restartInput(this)
|
||||
}
|
||||
return super.onKeyPreIme(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IME → host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
|
||||
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
|
||||
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
|
||||
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
|
||||
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
|
||||
* settles it. [setComposingRegion] adopts already-committed text as the active composition
|
||||
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
|
||||
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
|
||||
*
|
||||
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
|
||||
* deletes one scalar — true for the composition text IMEs actually produce (emoji and other
|
||||
* multi-unit graphemes commit directly rather than composing).
|
||||
*/
|
||||
private class HostTextConnection(
|
||||
view: KeyCaptureView,
|
||||
private val handle: Long,
|
||||
) : BaseInputConnection(view, true) {
|
||||
/** What the host currently shows of the active composition ("" = none). */
|
||||
private var sentComposition = ""
|
||||
|
||||
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
|
||||
retype(text.toString())
|
||||
sentComposition = ""
|
||||
val ok = super.commitText(text, newCursorPosition)
|
||||
trimEditable()
|
||||
return ok
|
||||
}
|
||||
|
||||
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
|
||||
retype(text.toString())
|
||||
return super.setComposingText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
override fun finishComposingText(): Boolean {
|
||||
// The composition text stands as committed — the host already shows it verbatim.
|
||||
sentComposition = ""
|
||||
return super.finishComposingText()
|
||||
}
|
||||
|
||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
||||
val e = editable
|
||||
if (e != null) {
|
||||
val a = start.coerceIn(0, e.length)
|
||||
val b = end.coerceIn(0, e.length)
|
||||
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
|
||||
}
|
||||
return super.setComposingRegion(start, end)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
|
||||
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun performEditorAction(actionCode: Int): Boolean {
|
||||
tapVk(VK_RETURN)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
|
||||
private fun retype(text: String) {
|
||||
var common = sentComposition.commonPrefixWith(text)
|
||||
// Never split a surrogate pair mid-diff — back off to the pair boundary.
|
||||
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
|
||||
common = common.dropLast(1)
|
||||
}
|
||||
val stale = sentComposition.substring(common.length)
|
||||
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
|
||||
sendText(text.substring(common.length))
|
||||
sentComposition = text
|
||||
}
|
||||
|
||||
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
|
||||
private fun sendText(s: String) {
|
||||
var chunk = StringBuilder()
|
||||
for (ch in s) {
|
||||
if (ch == '\n') {
|
||||
if (chunk.isNotEmpty()) {
|
||||
NativeBridge.nativeSendText(handle, chunk.toString())
|
||||
chunk = StringBuilder()
|
||||
}
|
||||
tapVk(VK_RETURN)
|
||||
} else {
|
||||
chunk.append(ch)
|
||||
}
|
||||
}
|
||||
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
|
||||
}
|
||||
|
||||
private fun tapVk(vk: Int) {
|
||||
NativeBridge.nativeSendKey(handle, vk, true, 0)
|
||||
NativeBridge.nativeSendKey(handle, vk, false, 0)
|
||||
}
|
||||
|
||||
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
|
||||
private fun trimEditable() {
|
||||
val e = editable ?: return
|
||||
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VK_BACK = 0x08
|
||||
const val VK_RETURN = 0x0D
|
||||
const val VK_DELETE = 0x2E
|
||||
const val MAX_TAPS = 256
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// Wire PEN_* state bits (punktfunk_core::quic::pen; mirrored, asserted by the Rust shim's docs).
|
||||
private const val PEN_IN_RANGE = 1f
|
||||
private const val PEN_TOUCHING = 2f
|
||||
private const val PEN_BARREL1 = 4f
|
||||
private const val PEN_BARREL2 = 8f
|
||||
private const val STRIDE = 10
|
||||
private const val MAX_SAMPLES = 8
|
||||
|
||||
/**
|
||||
* Android stylus → the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
|
||||
* (`AXIS_TILT`, radians from the surface normal), azimuth (`AXIS_ORIENTATION` — Android's 0 =
|
||||
* "pointed away from the user" IS the wire's north, no offset needed), hover with
|
||||
* `AXIS_DISTANCE`, the eraser tool, both stylus barrel buttons, and historical (coalesced)
|
||||
* samples batched oldest-first for full capture-rate fidelity. Android has no barrel-roll
|
||||
* axis — roll stays unknown on this client.
|
||||
*
|
||||
* Both touch loops call [intercept] first; stylus/eraser pointers are consumed here (against a
|
||||
* pen-capable host) and never reach the finger paths, independent of the touch-input mode.
|
||||
* [heartbeatLoop] implements the ≤100 ms keepalive wire contract: a stationary held stylus is
|
||||
* silent in Android's input pipeline, and the host force-releases a stroke after 200 ms
|
||||
* without samples.
|
||||
*/
|
||||
internal class StylusStream(private val handle: Long) {
|
||||
private var inRange = false
|
||||
private var touching = false
|
||||
private var sawHover = false
|
||||
private val last = FloatArray(STRIDE)
|
||||
private val batch = FloatArray(MAX_SAMPLES * STRIDE)
|
||||
|
||||
init {
|
||||
idle(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the event's stylus pointers into pen samples. Returns true when this event
|
||||
* carried any (the caller's finger/gesture handling must then skip those changes).
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
|
||||
val stylusChanges = ev.changes.filter {
|
||||
it.type == PointerType.Stylus || it.type == PointerType.Eraser
|
||||
}
|
||||
if (stylusChanges.isEmpty()) return false
|
||||
stylusChanges.forEach { it.consume() }
|
||||
val me = ev.motionEvent ?: return true
|
||||
if (size.width <= 0 || size.height <= 0) return true
|
||||
// At most one stylus exists — find its pointer index by tool type.
|
||||
val idx = (0 until me.pointerCount).firstOrNull {
|
||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
|
||||
me.getToolType(it) == MotionEvent.TOOL_TYPE_ERASER
|
||||
} ?: return true
|
||||
|
||||
when (me.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN,
|
||||
MotionEvent.ACTION_MOVE,
|
||||
-> {
|
||||
touching = true
|
||||
inRange = true
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
|
||||
sawHover = true
|
||||
inRange = true
|
||||
touching = false
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
|
||||
touching = false
|
||||
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
|
||||
// anything else leaves range on lift — the host never parks a phantom pen.
|
||||
inRange = sawHover
|
||||
emitSamples(me, idx, size)
|
||||
}
|
||||
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
|
||||
else -> {}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Session/composition teardown: leave range so the host lifts anything still inked. */
|
||||
fun reset() {
|
||||
if (inRange || touching) release()
|
||||
sawHover = false
|
||||
}
|
||||
|
||||
/** The ≤100 ms keepalive (80 ms leaves headroom for one lost datagram). Runs until
|
||||
* cancelled; resends the last state-full sample while the pen is in range. */
|
||||
suspend fun heartbeatLoop() {
|
||||
try {
|
||||
while (true) {
|
||||
delay(80)
|
||||
if (inRange || touching) {
|
||||
last[9] = 0f // dt
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
touching = false
|
||||
inRange = false
|
||||
last[0] = 0f // state: out of range
|
||||
last[4] = 0f // pressure
|
||||
NativeBridge.nativeSendPen(handle, last, 1)
|
||||
}
|
||||
|
||||
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
|
||||
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
|
||||
val history = minOf(me.historySize, MAX_SAMPLES - 1)
|
||||
var count = 0
|
||||
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
|
||||
for (h in (me.historySize - history) until me.historySize) {
|
||||
val t = me.getHistoricalEventTime(h)
|
||||
fill(
|
||||
batch, count * STRIDE, size,
|
||||
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
|
||||
pressure = me.getHistoricalPressure(idx, h),
|
||||
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
|
||||
orientRad = me.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, idx, h),
|
||||
distance = me.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, idx, h),
|
||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
||||
dtUs = ((t - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
||||
)
|
||||
prevT = t
|
||||
count++
|
||||
}
|
||||
fill(
|
||||
batch, count * STRIDE, size,
|
||||
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
|
||||
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
|
||||
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
|
||||
distance = me.getAxisValue(MotionEvent.AXIS_DISTANCE, idx),
|
||||
buttons = me.buttonState, tool = me.getToolType(idx),
|
||||
dtUs = ((me.eventTime - prevT) * 1000).coerceIn(0, 65535).toFloat(),
|
||||
)
|
||||
count++
|
||||
batch.copyInto(last, 0, (count - 1) * STRIDE, count * STRIDE)
|
||||
NativeBridge.nativeSendPen(handle, batch, count)
|
||||
}
|
||||
|
||||
private fun fill(
|
||||
out: FloatArray,
|
||||
off: Int,
|
||||
size: IntSize,
|
||||
x: Float,
|
||||
y: Float,
|
||||
pressure: Float,
|
||||
tiltRad: Float,
|
||||
orientRad: Float,
|
||||
distance: Float,
|
||||
buttons: Int,
|
||||
tool: Int,
|
||||
dtUs: Float,
|
||||
) {
|
||||
var state = 0f
|
||||
if (inRange || touching) state += PEN_IN_RANGE
|
||||
if (touching) state += PEN_TOUCHING
|
||||
if (buttons and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) state += PEN_BARREL1
|
||||
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
|
||||
out[off + 0] = state
|
||||
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
|
||||
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
|
||||
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
|
||||
// hovering legitimately means "at the hover floor".
|
||||
out[off + 5] = if (touching) 0f else distance.coerceIn(0f, 1f)
|
||||
out[off + 6] = Math.toDegrees(tiltRad.toDouble()).toFloat().coerceIn(0f, 90f)
|
||||
// AXIS_ORIENTATION: 0 = pointed away from the user (= wire north), clockwise, −π..π.
|
||||
out[off + 7] = ((Math.toDegrees(orientRad.toDouble()) + 360.0) % 360.0).toFloat()
|
||||
out[off + 8] = -1f // no barrel-roll axis on Android
|
||||
out[off + 9] = dtUs
|
||||
}
|
||||
|
||||
private fun idle(out: FloatArray) {
|
||||
out.fill(0f)
|
||||
out[5] = -1f // distance unknown
|
||||
out[6] = -1f // tilt unknown
|
||||
out[7] = -1f // azimuth unknown
|
||||
out[8] = -1f // roll unknown
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
@@ -58,26 +56,7 @@ private const val ACCEL_MAX = 3.0f
|
||||
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
|
||||
* contact is lifted so nothing stays stuck on the host.
|
||||
*/
|
||||
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
|
||||
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
|
||||
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
|
||||
|
||||
/** [awaitFirstDown] with the stylus lane split out: pen events feed [stylus] and never start a
|
||||
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
|
||||
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
|
||||
stylus: StylusStream?,
|
||||
): PointerInputChange {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val down = ev.changes.firstOrNull {
|
||||
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
|
||||
}
|
||||
if (down != null) return down
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
|
||||
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
|
||||
val ids = mutableMapOf<PointerId, Int>()
|
||||
fun alloc(p: PointerId): Int {
|
||||
var id = 0
|
||||
@@ -89,12 +68,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val sw = size.width
|
||||
val sh = size.height
|
||||
if (sw <= 0 || sh <= 0) continue
|
||||
for (c in ev.changes) {
|
||||
if (isStylus(c, stylus)) continue // the pen plane owns it
|
||||
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
|
||||
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
|
||||
when {
|
||||
@@ -121,13 +98,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
|
||||
|
||||
internal suspend fun PointerInputScope.streamTouchInput(
|
||||
handle: Long,
|
||||
stylus: StylusStream?,
|
||||
trackpad: Boolean,
|
||||
invertScroll: Boolean,
|
||||
onCycleStats: () -> Unit,
|
||||
onKeyboard: (show: Boolean) -> Unit,
|
||||
) {
|
||||
val scrollDir = if (invertScroll) -1 else 1
|
||||
var lastTapUp = 0L
|
||||
var lastTapX = 0f
|
||||
var lastTapY = 0f
|
||||
@@ -144,7 +118,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
)
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstFingerDown(stylus)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val startX = down.position.x
|
||||
val startY = down.position.y
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
|
||||
@@ -181,8 +155,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
stylus?.intercept(ev, size)
|
||||
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
|
||||
val pressed = ev.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) {
|
||||
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
|
||||
break
|
||||
@@ -211,12 +184,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
|
||||
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
|
||||
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
|
||||
if (sy != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
|
||||
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
|
||||
prevCy = cy
|
||||
moved = true
|
||||
}
|
||||
if (sx != 0) {
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
|
||||
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
|
||||
prevCx = cx
|
||||
moved = true
|
||||
}
|
||||
|
||||
@@ -106,17 +106,6 @@ object Keymap {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> 0x26
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
|
||||
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
|
||||
|
||||
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
|
||||
// MainActivity's pass-through list wins before the map is consulted).
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
|
||||
|
||||
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
|
||||
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
|
||||
|
||||
@@ -287,66 +287,6 @@ object NativeBridge {
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
/**
|
||||
* Whether the host advertised full-fidelity stylus injection (`HOST_CAP_PEN`) — the gate
|
||||
* for splitting stylus pointers out of the touch path onto the pen plane. False on `0`.
|
||||
*/
|
||||
external fun nativeHostSupportsPen(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* One stylus batch of STATE-FULL samples (the pen plane; design/pen-tablet-input.md §7):
|
||||
* [count] × 10 floats, oldest first — `[state, tool, x, y, pressure, distance, tilt_deg,
|
||||
* azimuth_deg, roll_deg, dt_us]`. `state` = the wire in-range/touching/barrel bits; `tool`
|
||||
* 0=pen 1=eraser; x/y/pressure/distance normalized 0..1; distance/tilt/azimuth/roll < 0 =
|
||||
* unknown. Send only when [nativeHostSupportsPen]; repeat the last sample ≤100 ms while the
|
||||
* pen is in range (the host force-releases a silent stroke after 200 ms).
|
||||
*/
|
||||
external fun nativeSendPen(handle: Long, samples: FloatArray, count: Int)
|
||||
|
||||
/**
|
||||
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) — its inject
|
||||
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
|
||||
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
|
||||
*/
|
||||
external fun nativeTextInputSupported(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* Committed IME text → one `TextInput` wire event per Unicode scalar, in order. Control
|
||||
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
|
||||
* when [nativeTextInputSupported] returned true — older hosts ignore the events.
|
||||
*/
|
||||
external fun nativeSendText(handle: Long, text: String)
|
||||
|
||||
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
|
||||
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
|
||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
||||
|
||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
||||
external fun nativeClipSupported(handle: Long): Boolean
|
||||
|
||||
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
|
||||
external fun nativeClipControl(handle: Long, enabled: Boolean)
|
||||
|
||||
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
|
||||
external fun nativeClipOfferText(handle: Long, seq: Int)
|
||||
|
||||
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
|
||||
external fun nativeClipFetchText(handle: Long, seq: Int): Int
|
||||
|
||||
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
|
||||
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
|
||||
|
||||
/** Abort a clipboard transfer by id (either direction). */
|
||||
external fun nativeClipCancel(handle: Long, id: Int)
|
||||
|
||||
/**
|
||||
* Block ≤250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
|
||||
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
|
||||
* `error:<id>:<code>` · `closed` (session gone) — null on timeout. Dedicated poll thread.
|
||||
*/
|
||||
external fun nativeNextClip(handle: Long): String?
|
||||
|
||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Shared-clipboard plane (text-only v1): Kotlin drives the Android `ClipboardManager`, these
|
||||
//! shims drive [`punktfunk_core::client::NativeClient`]'s clipboard surface.
|
||||
//!
|
||||
//! Model (mirrors the desktop clients): opt-in via `nativeClipControl(true)`; local copies are
|
||||
//! announced lazily as format-list offers (`nativeClipOfferText`) and the bytes cross only when
|
||||
//! the host pastes (a `fetch` event answered by `nativeClipServeText`); a host copy arrives as an
|
||||
//! `offer` event, which the Kotlin side fetches eagerly (Android's clipboard has no lazy provider
|
||||
//! path worth the complexity) and lands in the system clipboard on the `data` event.
|
||||
//!
|
||||
//! Events cross to Kotlin as compact strings from the blocking `nativeNextClip` poll (drained on
|
||||
//! a dedicated thread, same pattern as `nativeNextRumble`):
|
||||
//! `state:<0|1>` · `offer:<seq>:<has_text 0|1>` · `fetch:<req_id>` · `data:<xfer_id>:<text>` ·
|
||||
//! `cancel:<id>` · `error:<id>:<code>` · `closed` — null on a poll timeout. Non-text fetch
|
||||
//! requests are cancelled natively (only text is ever offered, so they shouldn't occur).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::clipboard::ClipEventCore;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
/// The portable wire MIME both ends map to their platform text type.
|
||||
const TEXT_MIME: &str = "text/plain;charset=utf-8";
|
||||
|
||||
/// Deref the opaque handle (`0` → `None`).
|
||||
///
|
||||
/// SAFETY: live handle per the nativeConnect/nativeClose contract; every method used is `&self`
|
||||
/// on the `Sync` connector.
|
||||
fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
if handle == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: see the function docs — the Kotlin side guarantees the handle outlives the call.
|
||||
Some(unsafe { &*(handle as *const SessionHandle) })
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
client(handle).map_or(0, |h| {
|
||||
u8::from(h.client.host_caps() & HOST_CAP_CLIPBOARD != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
enabled: jboolean,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_control(enabled != 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
||||
/// counter, newest wins.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_offer(
|
||||
seq as u32,
|
||||
vec![ClipKind {
|
||||
mime: TEXT_MIME.into(),
|
||||
size_hint: 0,
|
||||
}],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
seq: jint,
|
||||
) -> jint {
|
||||
client(handle)
|
||||
.and_then(|h| {
|
||||
h.client
|
||||
.clip_fetch(seq as u32, TEXT_MIME.into(), CLIP_FILE_INDEX_NONE)
|
||||
.ok()
|
||||
})
|
||||
.map_or(-1, |xfer| xfer as jint)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
req_id: jint,
|
||||
text: JString,
|
||||
) {
|
||||
let Some(h) = client(handle) else { return };
|
||||
let Ok(s) = env.get_string(&text) else {
|
||||
let _ = h.client.clip_cancel(req_id as u32);
|
||||
return;
|
||||
};
|
||||
let _ = h
|
||||
.client
|
||||
.clip_serve(req_id as u32, String::from(s).into_bytes(), true);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
id: jint,
|
||||
) {
|
||||
if let Some(h) = client(handle) {
|
||||
let _ = h.client.clip_cancel(id as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextClip(handle)` — block ≤250 ms for the next clipboard event, encoded
|
||||
/// as a compact string (module docs); null on timeout, `"closed"` once the session is gone.
|
||||
/// Call from a dedicated poll thread.
|
||||
///
|
||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
||||
/// can never split a UTF-8 sequence.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
let Some(h) = client(handle) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
let msg = match h.client.next_clip(Duration::from_millis(250)) {
|
||||
Ok(ClipEventCore::State { enabled, .. }) => format!("state:{}", u8::from(enabled)),
|
||||
Ok(ClipEventCore::RemoteOffer { seq, kinds }) => {
|
||||
let has_text = kinds.iter().any(|k| k.mime.starts_with("text/plain"));
|
||||
format!("offer:{seq}:{}", u8::from(has_text))
|
||||
}
|
||||
Ok(ClipEventCore::FetchRequest { req_id, mime, .. }) => {
|
||||
if mime.starts_with("text/plain") {
|
||||
format!("fetch:{req_id}")
|
||||
} else {
|
||||
// We only ever offer text; cancel anything else rather than stall the host.
|
||||
let _ = h.client.clip_cancel(req_id);
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
Ok(ClipEventCore::Data { xfer_id, bytes, .. }) => {
|
||||
format!("data:{xfer_id}:{}", String::from_utf8_lossy(&bytes))
|
||||
}
|
||||
Ok(ClipEventCore::Cancelled { id }) => format!("cancel:{id}"),
|
||||
Ok(ClipEventCore::Error { id, code }) => format!("error:{id}:{code}"),
|
||||
Err(PunktfunkError::NoFrame) => return std::ptr::null_mut(),
|
||||
Err(_) => "closed".into(),
|
||||
};
|
||||
env.new_string(msg)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
@@ -201,9 +201,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
|
||||
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
|
||||
None,
|
||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
||||
0,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
//! conventions: buttons 1=left/2=middle/3=right/4=X1/5=X2; scroll axis 0=vertical/1=horizontal,
|
||||
//! signed 120-unit delta, +=up/right; keys are Windows VK (mapped from KEYCODE_* on the Kotlin side).
|
||||
|
||||
use jni::objects::{JByteBuffer, JFloatArray, JObject, JString};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jboolean, jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{
|
||||
PenSample, PenTool, RichInput, HID_REPORT_MAX, HOST_CAP_PEN, HOST_CAP_TEXT_INPUT,
|
||||
PEN_ANGLE_UNKNOWN, PEN_BATCH_MAX, PEN_DISTANCE_UNKNOWN, PEN_TILT_UNKNOWN,
|
||||
};
|
||||
use punktfunk_core::quic::{RichInput, HID_REPORT_MAX};
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
@@ -148,132 +145,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
send_event(handle, kind, vk as u32, 0, 0, mods as u32);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
u8::from(h.client.host_caps() & HOST_CAP_TEXT_INPUT != 0)
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; host_caps is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
u8::from(h.client.host_caps() & HOST_CAP_PEN != 0)
|
||||
}
|
||||
|
||||
/// Floats per sample in the `nativeSendPen` flat array.
|
||||
const PEN_JNI_STRIDE: usize = 10;
|
||||
|
||||
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
|
||||
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
|
||||
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
|
||||
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
|
||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
||||
/// while in range (Kotlin side — see `StylusStream`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
samples: JFloatArray,
|
||||
count: jint,
|
||||
) {
|
||||
if handle == 0 || count <= 0 {
|
||||
return;
|
||||
}
|
||||
let count = (count as usize).min(PEN_BATCH_MAX);
|
||||
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
|
||||
let flat = &mut buf[..count * PEN_JNI_STRIDE];
|
||||
if env.get_float_array_region(&samples, 0, flat).is_err() {
|
||||
return; // short array — a bridge bug, never worth a crash on the input path
|
||||
}
|
||||
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
|
||||
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
|
||||
if !s[2].is_finite() || !s[3].is_finite() {
|
||||
return; // never forward a NaN coordinate
|
||||
}
|
||||
*slot = PenSample {
|
||||
state: s[0] as u8,
|
||||
tool: if s[1] as u8 == 1 {
|
||||
PenTool::Eraser
|
||||
} else {
|
||||
PenTool::Pen
|
||||
},
|
||||
x: s[2].clamp(0.0, 1.0),
|
||||
y: s[3].clamp(0.0, 1.0),
|
||||
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
distance: if s[5] < 0.0 {
|
||||
PEN_DISTANCE_UNKNOWN
|
||||
} else {
|
||||
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
|
||||
},
|
||||
tilt_deg: if s[6] < 0.0 {
|
||||
PEN_TILT_UNKNOWN
|
||||
} else {
|
||||
(s[6].clamp(0.0, 90.0)) as u8
|
||||
},
|
||||
azimuth_deg: if s[7] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[7] as u16) % 360
|
||||
},
|
||||
roll_deg: if s[8] < 0.0 {
|
||||
PEN_ANGLE_UNKNOWN
|
||||
} else {
|
||||
(s[8] as u16) % 360
|
||||
},
|
||||
dt_us: s[9].clamp(0.0, 65535.0) as u16,
|
||||
};
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_pen(&batch[..count]);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
|
||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
text: JString,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(s) = env.get_string(&text) else {
|
||||
return;
|
||||
};
|
||||
for ch in String::from(s).chars().filter(|c| !c.is_control()) {
|
||||
send_event(handle, InputKind::TextInput, ch as u32, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
|
||||
mod clipboard;
|
||||
mod connect;
|
||||
mod input;
|
||||
mod planes;
|
||||
|
||||
@@ -144,26 +144,14 @@ 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) || 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.
|
||||
#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.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background:
|
||||
guard model.phase == .streaming else { break }
|
||||
if backgroundKeepAlive {
|
||||
if backgroundKeepAlive, model.phase == .streaming {
|
||||
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()
|
||||
@@ -171,11 +159,7 @@ struct ContentView: View {
|
||||
break
|
||||
}
|
||||
}
|
||||
#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.
|
||||
// Live Activity lifecycle, driven from the model's published state.
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
@@ -990,9 +974,7 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
|
||||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
||||
let g = PunktfunkConnection.GamepadType(name: name) {
|
||||
// Back through resolveType so the lever is adopted as the session's setting: the
|
||||
// per-pad arrivals declare it too, which is what the host actually builds from.
|
||||
pad = GamepadManager.shared.resolveType(setting: g)
|
||||
pad = g
|
||||
}
|
||||
var bitrate = UInt32(clamping: bitrateKbps)
|
||||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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,11 +196,6 @@ 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`
|
||||
@@ -307,26 +302,13 @@ final class SessionModel: ObservableObject {
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
videoCodecs |= PunktfunkConnection.codecPyroWave
|
||||
}
|
||||
// Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop
|
||||
// mouse model advertise local cursor rendering — the host then stops compositing
|
||||
// the pointer and forwards shape/state, which StreamView draws as the real
|
||||
// NSCursor. Capture-mode sessions keep today's composited pointer.
|
||||
#if os(macOS)
|
||||
let clientCaps: UInt8 =
|
||||
(MouseInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "")
|
||||
?? .capture) == .desktop ? 0x01 : 0
|
||||
#else
|
||||
let clientCaps: UInt8 = 0
|
||||
#endif
|
||||
let result = Result { try PunktfunkConnection(
|
||||
host: host.address, port: host.port,
|
||||
width: width, height: height, refreshHz: hz,
|
||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||
audioChannels: audioChannels,
|
||||
videoCodecs: videoCodecs, preferredCodec: preferredCodec,
|
||||
clientCaps: clientCaps, launchID: launchID,
|
||||
videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID,
|
||||
// Delegated approval: the host holds this connect open until the operator approves
|
||||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||
// connects keep the snappy default.
|
||||
@@ -460,8 +442,6 @@ 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
|
||||
@@ -557,7 +537,6 @@ 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.
|
||||
|
||||
@@ -221,9 +221,6 @@ public final class PunktfunkConnection {
|
||||
/// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too:
|
||||
/// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple.
|
||||
private let clipboardLock = NSLock()
|
||||
/// Serializes the (single) cursor pull thread against close() — both cursor planes are
|
||||
/// drained by ONE thread, so one lock covers them.
|
||||
private let cursorLock = NSLock()
|
||||
|
||||
/// Negotiated session mode (host-confirmed).
|
||||
public private(set) var width: UInt32 = 0
|
||||
@@ -384,106 +381,6 @@ public final class PunktfunkConnection {
|
||||
public var hostSupportsClipboard: Bool {
|
||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
|
||||
}
|
||||
|
||||
/// The host answered `HOST_CAP_CURSOR`: it stopped compositing the pointer and forwards
|
||||
/// shape/state on the cursor planes — the client MUST draw the cursor locally.
|
||||
/// `0x08` — the bit moved when `HOST_CAP_TEXT_INPUT` claimed `0x04` on main; testing the
|
||||
/// old bit would mistake a text-input-capable host (e.g. Windows) for a cursor grant.
|
||||
public var hostSupportsCursor: Bool {
|
||||
hostCaps & 0x08 != 0
|
||||
}
|
||||
|
||||
/// The host injects full-fidelity stylus input (`HOST_CAP_PEN`) — the gate for splitting
|
||||
/// Apple Pencil out of the touch path onto the pen plane (``sendPen(_:)``).
|
||||
public var hostSupportsPen: Bool {
|
||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
|
||||
}
|
||||
|
||||
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
|
||||
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial` —
|
||||
/// states reference shapes by it and a re-shown serial never resends pixels.
|
||||
public struct CursorShapeEvent: Sendable {
|
||||
public let serial: UInt32
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hotX: Int
|
||||
public let hotY: Int
|
||||
public let rgba: Data
|
||||
}
|
||||
|
||||
/// Per-host-tick cursor state: position (host video px, the pointer/hotspot point),
|
||||
/// visibility, and the host-driven relative-mode hint (an app grabbed/hid the pointer ⇒
|
||||
/// run captured relative; clear ⇒ absolute, reappearing at `x`/`y`). Latest-wins.
|
||||
public struct CursorStateEvent: Sendable {
|
||||
public let serial: UInt32
|
||||
public let visible: Bool
|
||||
public let relativeHint: Bool
|
||||
public let x: Int32
|
||||
public let y: Int32
|
||||
}
|
||||
|
||||
/// Pull the next forwarded cursor SHAPE (nil = timeout). Only a session connected with
|
||||
/// `clientCaps` cursor bit against a `hostSupportsCursor` host receives any. Drain shape
|
||||
/// AND state from ONE dedicated cursor thread (they share a lock).
|
||||
public func nextCursorShape(timeoutMs: UInt32 = 0) throws -> CursorShapeEvent? {
|
||||
cursorLock.lock()
|
||||
defer { cursorLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
var out = PunktfunkCursorShape()
|
||||
let rc = punktfunk_connection_next_cursor_shape(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
// Copy out of the ABI borrow (valid until the next shape call) immediately.
|
||||
let bytes = out.rgba.map { Data(bytes: $0, count: Int(out.len)) } ?? Data()
|
||||
return CursorShapeEvent(
|
||||
serial: out.serial, width: Int(out.w), height: Int(out.h),
|
||||
hotX: Int(out.hot_x), hotY: Int(out.hot_y), rgba: bytes)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next cursor STATE (nil = timeout). Latest-wins — drain the queue and apply
|
||||
/// only the newest. Same thread + gate as [`nextCursorShape`].
|
||||
public func nextCursorState(timeoutMs: UInt32 = 0) throws -> CursorStateEvent? {
|
||||
cursorLock.lock()
|
||||
defer { cursorLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
var out = PunktfunkCursorState()
|
||||
let rc = punktfunk_connection_next_cursor_state(h, &out, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return CursorStateEvent(
|
||||
serial: out.serial,
|
||||
visible: out.flags & 0x01 != 0,
|
||||
relativeHint: out.flags & 0x02 != 0,
|
||||
x: out.x, y: out.y)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream mouse-model flip, ABI v12):
|
||||
/// `clientDraws = true` — this client draws it locally (the desktop mouse model; the host
|
||||
/// excludes the pointer from the video and forwards shape/state); `false` — the host
|
||||
/// composites it into the video (the capture model, full fidelity). Idempotent,
|
||||
/// latest-wins; harmless against hosts without the cursor cap. Fire-and-forget — errors
|
||||
/// are swallowed (a closed session is the only failure and it moots the flip).
|
||||
public func setCursorRender(clientDraws: Bool) {
|
||||
cursorLock.lock()
|
||||
defer { cursorLock.unlock() }
|
||||
guard let h = liveHandle() else { return }
|
||||
_ = punktfunk_connection_set_cursor_render(h, clientDraws)
|
||||
}
|
||||
|
||||
/// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing
|
||||
/// (Annex-B NAL parsing vs the AV1 OBU repack).
|
||||
public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) }
|
||||
@@ -520,7 +417,6 @@ public final class PunktfunkConnection {
|
||||
audioChannels: UInt8 = 2,
|
||||
videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode
|
||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
|
||||
launchID: String? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
@@ -540,18 +436,18 @@ public final class PunktfunkConnection {
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex9(
|
||||
punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex9(
|
||||
return punktfunk_connect_ex8(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
videoCodecs, preferredCodec, launch,
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
@@ -1141,19 +1037,6 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_send_input(h, &ev)
|
||||
}
|
||||
|
||||
/// Send one stylus sample batch (≤ `PUNKTFUNK_PEN_BATCH_MAX`, oldest first) on the pen
|
||||
/// plane. Gate on ``hostSupportsPen`` — the core refuses toward a host without the cap.
|
||||
/// Thread-safe; silently dropped after close (input is lossy by design).
|
||||
public func sendPen(_ samples: [PunktfunkPenSample]) {
|
||||
guard !samples.isEmpty else { return }
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
samples.withUnsafeBufferPointer { buf in
|
||||
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
|
||||
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
|
||||
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action — NOT from a
|
||||
@@ -1176,12 +1059,10 @@ public final class PunktfunkConnection {
|
||||
feedbackLock.lock()
|
||||
statsLock.lock()
|
||||
clipboardLock.lock()
|
||||
cursorLock.lock()
|
||||
abiLock.lock()
|
||||
let h = handle
|
||||
handle = nil
|
||||
abiLock.unlock()
|
||||
cursorLock.unlock()
|
||||
clipboardLock.unlock()
|
||||
statsLock.unlock()
|
||||
feedbackLock.unlock()
|
||||
|
||||
@@ -55,13 +55,7 @@ public final class GamepadCapture {
|
||||
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||
/// event this controller sends — the low byte of `flags`.
|
||||
let pad: UInt32
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened — the
|
||||
/// user's explicit "Controller type" setting when they picked one, else the detected
|
||||
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local feedback
|
||||
/// keys off the live `GCController` subclass instead, so whatever the host DOES send is
|
||||
/// applied natively to the pad in the user's hands. What the host sends is bounded by the
|
||||
/// emulated type, though — a virtual DualShock 4 has no adaptive-trigger reports in its
|
||||
/// protocol, so emulating one gives those up by construction (rumble + lightbar remain).
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||
let pref: PunktfunkConnection.GamepadType
|
||||
var buttons: UInt32 = 0
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
@@ -172,7 +166,7 @@ public final class GamepadCapture {
|
||||
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||
let c = dc.controller
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||
slots.append(slot)
|
||||
|
||||
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||
@@ -198,12 +192,9 @@ public final class GamepadCapture {
|
||||
}
|
||||
}
|
||||
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||
// matching virtual device — the user's chosen type when they picked one, else per-pad
|
||||
// detection (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
|
||||
// what the host actually builds from, so it MUST carry an explicit setting; the
|
||||
// handshake's session default is only the fallback for a pad that never declares. The
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
|
||||
@@ -131,40 +131,13 @@ public final class GamepadManager: ObservableObject {
|
||||
GCController.stopWirelessControllerDiscovery()
|
||||
}
|
||||
|
||||
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed —
|
||||
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
|
||||
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
|
||||
|
||||
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
|
||||
/// An explicit setting wins for every pad — the handshake's session default alone does NOT
|
||||
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
|
||||
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
|
||||
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
|
||||
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
|
||||
public func declaredKind(
|
||||
for controller: DiscoveredController
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
Self.declaredKind(setting: typeSetting, detected: controller.kind)
|
||||
}
|
||||
|
||||
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
|
||||
nonisolated static func declaredKind(
|
||||
setting: PunktfunkConnection.GamepadType,
|
||||
detected: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
setting == .auto ? detected : setting
|
||||
}
|
||||
|
||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||
/// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
|
||||
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
|
||||
/// arrival can never disagree about an explicit choice.
|
||||
/// 360); no controller at all defers to the host.
|
||||
public func resolveType(
|
||||
setting: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
typeSetting = setting
|
||||
guard setting == .auto else { return setting }
|
||||
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
|
||||
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
// Apple Pencil → state-full wire pen samples (design/pen-tablet-input.md §7).
|
||||
//
|
||||
// Every sample carries the COMPLETE pen state (in-range/touching/buttons + all axes) — the
|
||||
// host diffs consecutive samples and synthesizes down/up/button transitions itself, so a lost
|
||||
// datagram self-heals and this file never sends edge events. Three sources feed one stream:
|
||||
// UITouch contacts (with coalesced samples for full 240 Hz fidelity), the hover gesture
|
||||
// (zOffset > 0 distinguishes a hovering Pencil from a trackpad pointer), and
|
||||
// UIPencilInteraction (squeeze held → barrel 1, double-tap → a momentary barrel 2 —
|
||||
// Apple Pencil has no hardware eraser end or barrel buttons, so these mappings are how
|
||||
// host-side apps get their stylus button/eraser affordances).
|
||||
//
|
||||
// HEARTBEAT (wire contract — see `PunktfunkPenSample` in punktfunk_core.h): while the pen is
|
||||
// in range or touching, the last sample repeats every ≤100 ms even when nothing changed.
|
||||
// UIKit is silent for a stationary Pencil, and the host force-releases the stroke after
|
||||
// 200 ms without samples (its dead-client failsafe) — the timer keeps a held stroke alive.
|
||||
|
||||
#if os(iOS)
|
||||
import PunktfunkCore
|
||||
import UIKit
|
||||
|
||||
final class PencilStream: NSObject, UIPencilInteractionDelegate {
|
||||
enum Phase { case down, move, up, cancel }
|
||||
|
||||
/// One assembled batch (≤ `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
|
||||
var send: (([PunktfunkPenSample]) -> Void)?
|
||||
/// View-space point → normalized [0,1] video coordinates (the letterbox mapping the
|
||||
/// touch path already uses). nil until a mode is negotiated — samples are dropped then.
|
||||
var videoNorm: ((CGPoint) -> (Float, Float)?)?
|
||||
|
||||
private var inRange = false
|
||||
private var touching = false
|
||||
/// Squeeze held (mapped to wire BARREL1).
|
||||
private var squeezeHeld = false
|
||||
/// Whether this device/Pencil pair has demonstrated hover — decides what a lift means:
|
||||
/// hover-capable hardware keeps proximity (the hover recognizer owns the exit), anything
|
||||
/// else leaves range on lift so the host never parks a phantom hovering pen.
|
||||
private var sawHover = false
|
||||
/// A hover gesture is live right now (routes ended-state hover callbacks to us even when
|
||||
/// the recognizer's final zOffset reads 0).
|
||||
private(set) var hoverActive = false
|
||||
private var last = PencilStream.idleSample()
|
||||
private var heartbeat: Timer?
|
||||
|
||||
// MARK: - Contact path (UITouch, `.pencil` only)
|
||||
|
||||
func touches(_ touches: Set<UITouch>, event: UIEvent?, phase: Phase, in view: UIView) {
|
||||
// At most one Pencil exists; a set with several is UIKit batching phases of the same
|
||||
// stylus — the last one carries the freshest state.
|
||||
guard let touch = touches.max(by: { $0.timestamp < $1.timestamp }) else { return }
|
||||
switch phase {
|
||||
case .down, .move:
|
||||
touching = true
|
||||
inRange = true
|
||||
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
|
||||
// display cadence); oldest first, `dt_us` preserving their spacing.
|
||||
let raw = event?.coalescedTouches(for: touch) ?? [touch]
|
||||
var batch: [PunktfunkPenSample] = []
|
||||
var prevTs: TimeInterval?
|
||||
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
|
||||
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
|
||||
prevTs = t.timestamp
|
||||
batch.append(s)
|
||||
}
|
||||
emit(batch)
|
||||
case .up:
|
||||
touching = false
|
||||
// Hover-capable hardware: lift back to hover, the recognizer exits range later.
|
||||
// Otherwise a lift IS the range exit (mirror of the host's GameStream heuristic).
|
||||
inRange = sawHover
|
||||
var s = last
|
||||
s.pressure = 0
|
||||
s.state = stateBits()
|
||||
if let posSample = contactSample(touch, in: view, prevTs: nil) {
|
||||
s.x = posSample.x
|
||||
s.y = posSample.y
|
||||
}
|
||||
emit([s])
|
||||
case .cancel:
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hover path (forwarded from the view's hover recognizer)
|
||||
|
||||
/// Returns whether the event was consumed as Pencil hover; `false` hands it back to the
|
||||
/// pointer path. A hovering Pencil reports `zOffset > 0`; trackpad/mouse hover is 0.
|
||||
func maybeHover(_ r: UIHoverGestureRecognizer, in view: UIView) -> Bool {
|
||||
switch r.state {
|
||||
case .began, .changed:
|
||||
guard r.zOffset > 0 || hoverActive else { return false }
|
||||
hoverActive = true
|
||||
sawHover = true
|
||||
inRange = true
|
||||
touching = false
|
||||
guard let (x, y) = videoNorm?(r.location(in: view)) else { return true }
|
||||
var s = PencilStream.idleSample()
|
||||
s.state = stateBits()
|
||||
s.x = x
|
||||
s.y = y
|
||||
s.distance = UInt16((r.zOffset.clamped(to: 0...1) * 65534).rounded())
|
||||
s.tilt_deg = Self.tiltDeg(altitude: r.altitudeAngle)
|
||||
s.azimuth_deg = Self.azimuthDeg(r.azimuthAngle(in: view))
|
||||
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(r.rollAngle) }
|
||||
emit([s])
|
||||
return true
|
||||
case .ended, .cancelled, .failed:
|
||||
guard hoverActive else { return false }
|
||||
hoverActive = false
|
||||
if !touching { release() }
|
||||
return true
|
||||
default:
|
||||
return hoverActive
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIPencilInteractionDelegate (squeeze → barrel 1 held, tap → barrel 2 click)
|
||||
|
||||
@available(iOS 17.5, *)
|
||||
func pencilInteraction(
|
||||
_ interaction: UIPencilInteraction, didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze
|
||||
) {
|
||||
switch squeeze.phase {
|
||||
case .began:
|
||||
squeezeHeld = true
|
||||
case .ended, .cancelled:
|
||||
squeezeHeld = false
|
||||
default:
|
||||
return
|
||||
}
|
||||
guard inRange || touching else { return }
|
||||
var s = last
|
||||
s.state = stateBits()
|
||||
emit([s])
|
||||
}
|
||||
|
||||
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
|
||||
guard inRange || touching else { return }
|
||||
// A momentary barrel-2 click: press + release as two state-full samples in ONE batch
|
||||
// — the host's tracker emits the button press and release in order.
|
||||
var press = last
|
||||
press.state = stateBits() | UInt8(PUNKTFUNK_PEN_BARREL2)
|
||||
var releaseS = last
|
||||
releaseS.state = stateBits()
|
||||
emit([press, releaseS])
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Session stop / view teardown: leave range so the host lifts anything held.
|
||||
func reset() {
|
||||
if inRange || touching { release() }
|
||||
sawHover = false
|
||||
hoverActive = false
|
||||
squeezeHeld = false
|
||||
}
|
||||
|
||||
private func release() {
|
||||
touching = false
|
||||
inRange = false
|
||||
var s = last
|
||||
s.pressure = 0
|
||||
s.state = 0
|
||||
emit([s])
|
||||
}
|
||||
|
||||
// MARK: - Sample assembly
|
||||
|
||||
private func contactSample(
|
||||
_ t: UITouch, in view: UIView, prevTs: TimeInterval?
|
||||
) -> PunktfunkPenSample? {
|
||||
guard let (x, y) = videoNorm?(t.location(in: view)) else { return nil }
|
||||
var s = PencilStream.idleSample()
|
||||
s.state = stateBits()
|
||||
s.x = x
|
||||
s.y = y
|
||||
// maximumPossibleForce is 0 until the system knows the stylus — full force then
|
||||
// (binary-stylus semantics, matching the host's unknown-pressure rule).
|
||||
let maxForce = t.maximumPossibleForce
|
||||
s.pressure =
|
||||
maxForce > 0
|
||||
? UInt16((Double(t.force / maxForce).clamped(to: 0...1) * 65535).rounded())
|
||||
: UInt16.max
|
||||
s.distance = 0
|
||||
s.tilt_deg = Self.tiltDeg(altitude: t.altitudeAngle)
|
||||
s.azimuth_deg = Self.azimuthDeg(t.azimuthAngle(in: view))
|
||||
if #available(iOS 17.5, *) { s.roll_deg = Self.rollDeg(t.rollAngle) }
|
||||
if let prevTs {
|
||||
s.dt_us = UInt16(((t.timestamp - prevTs) * 1_000_000).clamped(to: 0...65535))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private func stateBits() -> UInt8 {
|
||||
var bits: UInt8 = 0
|
||||
if inRange || touching { bits |= UInt8(PUNKTFUNK_PEN_IN_RANGE) }
|
||||
if touching { bits |= UInt8(PUNKTFUNK_PEN_TOUCHING) }
|
||||
if squeezeHeld { bits |= UInt8(PUNKTFUNK_PEN_BARREL1) }
|
||||
return bits
|
||||
}
|
||||
|
||||
private func emit(_ batch: [PunktfunkPenSample]) {
|
||||
guard !batch.isEmpty else { return }
|
||||
last = batch[batch.count - 1]
|
||||
last.dt_us = 0
|
||||
send?(batch)
|
||||
armHeartbeat()
|
||||
}
|
||||
|
||||
/// The ≤100 ms keepalive while in range (see the file header). 80 ms leaves headroom
|
||||
/// under the host's 200 ms failsafe even with one lost datagram.
|
||||
private func armHeartbeat() {
|
||||
heartbeat?.invalidate()
|
||||
guard inRange || touching else {
|
||||
heartbeat = nil
|
||||
return
|
||||
}
|
||||
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
|
||||
[weak self] _ in
|
||||
guard let self, self.inRange || self.touching else {
|
||||
self?.heartbeat?.invalidate()
|
||||
self?.heartbeat = nil
|
||||
return
|
||||
}
|
||||
self.send?([self.last])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Angle conversions
|
||||
|
||||
/// Altitude (π/2 = perpendicular) → wire tilt-from-normal in degrees, 0...90.
|
||||
private static func tiltDeg(altitude: CGFloat) -> UInt8 {
|
||||
UInt8((90 - altitude * 180 / .pi).rounded().clamped(to: 0...90))
|
||||
}
|
||||
|
||||
/// Apple azimuth (0 along the view's +x axis, clockwise, y-down) → wire azimuth
|
||||
/// (0 = north/up on screen, clockwise): +90° offset.
|
||||
private static func azimuthDeg(_ apple: CGFloat) -> UInt16 {
|
||||
let deg = (apple * 180 / .pi + 90).truncatingRemainder(dividingBy: 360)
|
||||
return UInt16((deg + 360).truncatingRemainder(dividingBy: 360).rounded()) % 360
|
||||
}
|
||||
|
||||
/// Pencil Pro roll (radians, −π...π) → wire barrel roll 0...359°.
|
||||
private static func rollDeg(_ roll: CGFloat) -> UInt16 {
|
||||
let deg = (roll * 180 / .pi).truncatingRemainder(dividingBy: 360)
|
||||
return UInt16(((deg + 360).truncatingRemainder(dividingBy: 360)).rounded()) % 360
|
||||
}
|
||||
|
||||
/// All-unknown baseline: sentinel angles/distance, tool = pen (the eraser is host-side
|
||||
/// state driven by the squeeze/tap mappings, not a hardware end).
|
||||
private static func idleSample() -> PunktfunkPenSample {
|
||||
PunktfunkPenSample(
|
||||
x: 0, y: 0, pressure: 0,
|
||||
distance: UInt16(PUNKTFUNK_PEN_DISTANCE_UNKNOWN),
|
||||
azimuth_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
|
||||
roll_deg: UInt16(PUNKTFUNK_PEN_ANGLE_UNKNOWN),
|
||||
dt_us: 0, state: 0,
|
||||
tool: UInt8(PUNKTFUNK_PEN_TOOL_PEN),
|
||||
tilt_deg: UInt8(PUNKTFUNK_PEN_TILT_UNKNOWN),
|
||||
_reserved: (0, 0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
extension Comparable {
|
||||
fileprivate func clamped(to range: ClosedRange<Self>) -> Self {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -219,39 +219,6 @@ public final class StreamLayerView: NSView {
|
||||
/// flipped live by ⌃⌥⇧M. A live flip re-engages capture in the new model so
|
||||
/// disassociation + the abs/rel choice swap atomically. Main-thread only.
|
||||
private var desktopMouse = false
|
||||
/// Cursor channel (M2): the host forwards shape/state and WE draw the pointer. Active
|
||||
/// when the Welcome carried `HOST_CAP_CURSOR` (only sessions that advertised the client
|
||||
/// cap get it). Shapes cache by serial; state is latest-wins. Main-thread only.
|
||||
private var cursorChannelActive = false
|
||||
/// A forwarded host cursor shape, cached RAW (not as a finished `NSCursor`) so the pointer can be
|
||||
/// (re)built at the CURRENT video-fit scale — see `scaledCursor`. The host forwards the bitmap in
|
||||
/// host FRAMEBUFFER pixels, whose size tracks the host's display scaling (32 px at 100%, 96 px at
|
||||
/// 300% DPI); scaling by the video fit keeps the pointer sized to the streamed desktop at any host
|
||||
/// scaling instead of ballooning on a high-DPI host.
|
||||
private struct HostCursorShape {
|
||||
let cg: CGImage
|
||||
let width: Int
|
||||
let height: Int
|
||||
let hotX: Int
|
||||
let hotY: Int
|
||||
}
|
||||
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
||||
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
|
||||
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
|
||||
/// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst
|
||||
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
|
||||
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
|
||||
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
|
||||
private var lastWornShape: HostCursorShape?
|
||||
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
||||
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
||||
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
||||
/// model, so the chord, engage/release, and session start all reconcile through one path.
|
||||
private var sentClientDraws: Bool?
|
||||
/// M3 hint tracking: edge-triggered so a manual ⌃⌥⇧M isn't fought — the override latch
|
||||
/// holds until the HOST's intent next changes.
|
||||
private var lastHint: Bool?
|
||||
private var hintOverride = false
|
||||
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
||||
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
||||
/// surprisingly later (e.g. on a resize).
|
||||
@@ -489,7 +456,6 @@ public final class StreamLayerView: NSView {
|
||||
window?.makeFirstResponder(self)
|
||||
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
|
||||
notifyCaptureChange(true)
|
||||
reconcileCursorRender()
|
||||
}
|
||||
|
||||
private func releaseCapture() {
|
||||
@@ -500,7 +466,6 @@ public final class StreamLayerView: NSView {
|
||||
captured = false
|
||||
window?.invalidateCursorRects(for: self)
|
||||
notifyCaptureChange(false)
|
||||
reconcileCursorRender() // released ⇒ the host composites the pointer again
|
||||
}
|
||||
|
||||
/// A fully transparent cursor for the desktop mouse model's hide-over-view rect —
|
||||
@@ -515,190 +480,12 @@ public final class StreamLayerView: NSView {
|
||||
/// globally via `CursorCapture` (the pointer can't leave the view there).
|
||||
override public func resetCursorRects() {
|
||||
if captured && desktopMouse {
|
||||
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
||||
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
|
||||
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
|
||||
// visible one.
|
||||
//
|
||||
// A visible pointer whose announced serial has no bitmap yet falls back to the last
|
||||
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
|
||||
// routine, not degenerate — state outruns its bitmap on every single shape change —
|
||||
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
|
||||
// shape arrived late or got dropped, with no recovery until the next change. Only
|
||||
// `st.visible == false` may hide the pointer; a missing bitmap may not.
|
||||
if cursorChannelActive, let st = cursorState, st.visible,
|
||||
let shape = hostCursors[st.serial] ?? lastWornShape {
|
||||
lastWornShape = shape
|
||||
addCursorRect(bounds, cursor: scaledCursor(shape))
|
||||
} else {
|
||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||
}
|
||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||
} else {
|
||||
super.resetCursorRects()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
|
||||
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
|
||||
/// the capture model — and while released — the host composites it into the video (full
|
||||
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
|
||||
/// transition (chord, engage/release, session start).
|
||||
private func reconcileCursorRender() {
|
||||
guard cursorChannelActive, let connection else { return }
|
||||
let clientDraws = captured && desktopMouse
|
||||
guard sentClientDraws != clientDraws else { return }
|
||||
sentClientDraws = clientDraws
|
||||
connection.setCursorRender(clientDraws: clientDraws)
|
||||
}
|
||||
|
||||
/// Flip the mouse model with the atomic release/re-engage swap; `reappearAt` (host video
|
||||
/// px — the M3 hand-back position) warps the local pointer so leaving relative lands the
|
||||
/// cursor exactly where the host last had it.
|
||||
private func setDesktopMouse(_ on: Bool, reappearAt: (x: Int32, y: Int32)?) {
|
||||
guard desktopMouse != on else { return }
|
||||
let wasCaptured = captured
|
||||
if wasCaptured { releaseCapture() }
|
||||
desktopMouse = on
|
||||
if wasCaptured { engageCapture(fromClick: false) }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) {
|
||||
CGWarpMouseCursorPosition(sp)
|
||||
}
|
||||
reconcileCursorRender()
|
||||
}
|
||||
|
||||
/// The single cursor pull thread (both planes share the connection's cursor lock):
|
||||
/// latest-wins state at a short timeout + a non-blocking shape poll per iteration.
|
||||
/// Exits when the connection closes; events hop to main where all cursor state lives.
|
||||
private func startCursorPump(_ connection: PunktfunkConnection) {
|
||||
let thread = Thread { [weak self] in
|
||||
while true {
|
||||
do {
|
||||
var newest: PunktfunkConnection.CursorStateEvent?
|
||||
if let st = try connection.nextCursorState(timeoutMs: 100) {
|
||||
newest = st
|
||||
while let more = try connection.nextCursorState(timeoutMs: 0) {
|
||||
newest = more // drain — latest wins
|
||||
}
|
||||
}
|
||||
while let shape = try connection.nextCursorShape(timeoutMs: 0) {
|
||||
DispatchQueue.main.async { self?.applyCursorShape(shape) }
|
||||
}
|
||||
if let st = newest {
|
||||
DispatchQueue.main.async { self?.applyCursorState(st) }
|
||||
}
|
||||
} catch {
|
||||
return // connection closed — the session is over
|
||||
}
|
||||
if self == nil { return }
|
||||
}
|
||||
}
|
||||
thread.name = "pf-cursor-pump"
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
||||
guard let shape = Self.makeShape(ev) else {
|
||||
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
|
||||
// a rejection here left the announced serial with no bitmap and HID the pointer.
|
||||
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
||||
return
|
||||
}
|
||||
if hostCursors.count >= 64 { hostCursors.removeAll() } // degenerate host: reset
|
||||
hostCursors[ev.serial] = shape
|
||||
if cursorState?.serial == ev.serial {
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCursorState(_ ev: PunktfunkConnection.CursorStateEvent) {
|
||||
let prev = cursorState
|
||||
cursorState = ev
|
||||
if prev?.visible != ev.visible || prev?.serial != ev.serial {
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
// M3 host-driven auto-flip is DISABLED: `relative_hint` is derived from host cursor
|
||||
// VISIBILITY, and Windows hides the pointer for ordinary desktop activity (clicking,
|
||||
// typing) — not just when a game grabs it. Acting on those transients flipped
|
||||
// desktop→capture→desktop, which warped the cursor to view-centre and flushed held
|
||||
// buttons (a spurious button-up ~200 ms into every press → broke window drags). Until
|
||||
// the host exposes a real pointer-LOCK signal (ClipCursor/raw-input, not visibility),
|
||||
// the mouse model is user-driven only (⌃⌥⇧M). The hint still rides the wire, unused.
|
||||
_ = (lastHint, hintOverride)
|
||||
}
|
||||
|
||||
/// Decode a forwarded straight-alpha RGBA shape into a CGImage + hotspot. The on-screen SIZE is
|
||||
/// NOT baked in here — it is applied per-use in `scaledCursor` from the live video-fit scale, so
|
||||
/// the same shape re-fits across window resizes / retina moves without a re-forward.
|
||||
private static func makeShape(_ ev: PunktfunkConnection.CursorShapeEvent) -> HostCursorShape? {
|
||||
let (w, h) = (ev.width, ev.height)
|
||||
guard w > 0, h > 0, ev.rgba.count >= w * h * 4,
|
||||
let provider = CGDataProvider(data: ev.rgba as CFData),
|
||||
let cg = CGImage(
|
||||
width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32,
|
||||
bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
|
||||
provider: provider, decode: nil, shouldInterpolate: false,
|
||||
intent: .defaultIntent)
|
||||
else { return nil }
|
||||
return HostCursorShape(
|
||||
cg: cg, width: w, height: h,
|
||||
hotX: min(ev.hotX, w - 1), hotY: min(ev.hotY, h - 1))
|
||||
}
|
||||
|
||||
/// Points-per-host-pixel: the exact factor the video frame is aspect-fit into the view (the same
|
||||
/// `AVMakeRect` fit `hostPoint`/`cgScreenPoint` use). The host forwards the pointer bitmap in host
|
||||
/// framebuffer pixels — the mode we drive is in the client's BACKING pixels, so on retina this is
|
||||
/// ~1/backingScale and the pointer lands at its TRUE size relative to the streamed desktop
|
||||
/// (crisp, 1:1 with the video) rather than the 2×-inflated pixel-as-points it used to be. Because
|
||||
/// the bitmap grows with the host's display scaling (96 px at 300% DPI), scaling by this is what
|
||||
/// keeps a high-DPI host from forwarding a giant pointer. Falls back to 1 before the first
|
||||
/// mode/layout.
|
||||
private func cursorFitScale() -> CGFloat {
|
||||
guard let connection else { return 1 }
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0, bounds.width > 0, bounds.height > 0 else { return 1 }
|
||||
let fit = AVMakeRect(
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), insideRect: bounds)
|
||||
guard fit.width > 0 else { return 1 }
|
||||
return fit.width / CGFloat(mode.width)
|
||||
}
|
||||
|
||||
/// Build the `NSCursor` for a cached shape at the CURRENT video-fit scale (see `cursorFitScale`).
|
||||
/// Both the image size and the hotspot scale together so the click point stays true.
|
||||
private func scaledCursor(_ shape: HostCursorShape) -> NSCursor {
|
||||
let scale = cursorFitScale()
|
||||
let sw = max(1, (CGFloat(shape.width) * scale).rounded())
|
||||
let sh = max(1, (CGFloat(shape.height) * scale).rounded())
|
||||
let image = NSImage(cgImage: shape.cg, size: NSSize(width: sw, height: sh))
|
||||
let hot = NSPoint(
|
||||
x: min(CGFloat(shape.hotX) * scale, sw - 1),
|
||||
y: min(CGFloat(shape.hotY) * scale, sh - 1))
|
||||
return NSCursor(image: image, hotSpot: hot)
|
||||
}
|
||||
|
||||
/// Host video px → CG GLOBAL screen coordinates (top-left origin, the
|
||||
/// `CGWarpMouseCursorPosition` convention `CursorCapture` established) through the
|
||||
/// aspect-fit letterbox — the inverse direction of `hostPoint(from:)`.
|
||||
private func cgScreenPoint(forHostX hx: Int32, _ hy: Int32) -> CGPoint? {
|
||||
guard let connection, let window else { return nil }
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0 else { return nil }
|
||||
let fit = AVMakeRect(
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
guard fit.width > 0, fit.height > 0 else { return nil }
|
||||
let u = (CGFloat(hx) / CGFloat(mode.width)).clamped(to: 0...1)
|
||||
let v = (CGFloat(hy) / CGFloat(mode.height)).clamped(to: 0...1)
|
||||
let videoMinYTop = bounds.height - fit.maxY
|
||||
let pTop = CGPoint(x: fit.minX + u * fit.width, y: videoMinYTop + v * fit.height)
|
||||
let inView = CGPoint(x: pTop.x, y: bounds.height - pTop.y)
|
||||
let inWindow = convert(inView, to: nil)
|
||||
let onScreen = window.convertPoint(toScreen: inWindow)
|
||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
|
||||
return CGPoint(x: onScreen.x, y: primaryHeight - onScreen.y)
|
||||
}
|
||||
|
||||
/// A single local monitor for motion + buttons, installed only while captured. A local
|
||||
/// monitor is more robust than view overrides for relative motion: it sidesteps the
|
||||
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
|
||||
@@ -860,10 +647,12 @@ public final class StreamLayerView: NSView {
|
||||
streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only")
|
||||
return
|
||||
}
|
||||
// A manual flip outranks the standing host hint until the hint next CHANGES.
|
||||
self.hintOverride = true
|
||||
self.setDesktopMouse(!self.desktopMouse, reappearAt: nil)
|
||||
streamInputLog.info("chord: mouse mode \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
|
||||
let wasCaptured = self.captured
|
||||
if wasCaptured { self.releaseCapture() }
|
||||
self.desktopMouse.toggle()
|
||||
if wasCaptured { self.engageCapture(fromClick: false) }
|
||||
self.window?.invalidateCursorRects(for: self)
|
||||
streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
|
||||
}
|
||||
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
||||
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
||||
@@ -903,14 +692,6 @@ public final class StreamLayerView: NSView {
|
||||
if mode == .desktop && !absOK {
|
||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
||||
}
|
||||
// Cursor channel (M2): the host stopped compositing the pointer — drain its shape/
|
||||
// state planes and draw the pointer as the real NSCursor (plus the M3 auto-flip).
|
||||
if connection.hostSupportsCursor {
|
||||
cursorChannelActive = true
|
||||
streamInputLog.info("cursor channel negotiated — host cursor renders locally")
|
||||
startCursorPump(connection)
|
||||
reconcileCursorRender() // initial render mode (a capture-model start composites)
|
||||
}
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
@@ -970,11 +751,6 @@ public final class StreamLayerView: NSView {
|
||||
matchFollower?.noteSize(
|
||||
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
|
||||
}
|
||||
// The video-fit scale just changed (resize / retina move); rebuild the worn host pointer at
|
||||
// the new scale so it tracks the video instead of freezing at its build-time size.
|
||||
if captured, desktopMouse, cursorChannelActive {
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidChangeBackingProperties() {
|
||||
@@ -1005,14 +781,6 @@ public final class StreamLayerView: NSView {
|
||||
matchFollower = nil
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
// Cursor-channel state is per-session: without this reset a next session against a
|
||||
// host WITHOUT the cap would wear this session's stale shapes (`cursorChannelActive`
|
||||
// stayed latched true across sessions).
|
||||
cursorChannelActive = false
|
||||
cursorState = nil
|
||||
hostCursors.removeAll()
|
||||
sentClientDraws = nil
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -333,13 +333,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard self?.captureEnabled == true else { return }
|
||||
connection?.send(event)
|
||||
}
|
||||
// Apple Pencil → the stylus plane, only against a pen-capable host (elsewhere the
|
||||
// Pencil stays a finger, exactly as before). Same trust gate as touch.
|
||||
streamView.penEnabled = connection.hostSupportsPen
|
||||
streamView.onPenBatch = { [weak self, weak connection] batch in
|
||||
guard self?.captureEnabled == true else { return }
|
||||
connection?.sendPen(batch)
|
||||
}
|
||||
// Indirect pointer (mouse/trackpad) WITHOUT a lock → absolute cursor + buttons + scroll.
|
||||
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
|
||||
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
|
||||
@@ -506,8 +499,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// onTouchEvent can still deliver the button-up.
|
||||
streamView.resetTouchInput()
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.onPenBatch = nil // after reset — the pen's leave-range sample rides it
|
||||
streamView.penEnabled = false
|
||||
streamView.onPointerMoveAbs = nil
|
||||
streamView.onPointerButton = nil
|
||||
streamView.onScroll = nil
|
||||
@@ -701,12 +692,6 @@ final class StreamLayerUIView: UIView {
|
||||
/// Direct fingers / Pencil → wire events: real touches in passthrough mode, or the
|
||||
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
|
||||
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||
/// Apple Pencil → state-full pen sample batches (the stylus plane). Active only while
|
||||
/// `penEnabled`; without it the Pencil stays on the finger path exactly as before.
|
||||
var onPenBatch: (([PunktfunkPenSample]) -> Void)?
|
||||
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
|
||||
/// the pen plane — independent of the touch-input mode (drawing must not depend on it).
|
||||
var penEnabled = false
|
||||
/// Indirect pointer (mouse/trackpad with no lock) → absolute cursor moves.
|
||||
var onPointerMoveAbs: ((HostPoint) -> Void)?
|
||||
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
|
||||
@@ -730,21 +715,10 @@ final class StreamLayerUIView: UIView {
|
||||
/// The finger route latched at gesture start — a Settings change mid-gesture applies to
|
||||
/// the NEXT touch, so one gesture never splits across input models.
|
||||
private var fingerRoute: TouchInputMode?
|
||||
/// The Apple Pencil pipeline (contacts + hover + squeeze/tap → pen samples).
|
||||
private lazy var pencil: PencilStream = {
|
||||
let stream = PencilStream()
|
||||
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
|
||||
stream.videoNorm = { [weak self] point in
|
||||
guard let h = self?.hostPoint(from: point) else { return nil }
|
||||
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
|
||||
}
|
||||
return stream
|
||||
}()
|
||||
|
||||
/// Release anything the touch-driven mouse holds and forget gesture state — session stop.
|
||||
func resetTouchInput() {
|
||||
touchMouse.reset()
|
||||
pencil.reset() // leaves range → the host lifts anything still inked
|
||||
fingerRoute = nil
|
||||
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
|
||||
}
|
||||
@@ -781,11 +755,6 @@ final class StreamLayerUIView: UIView {
|
||||
scrollPan.allowedScrollTypesMask = .all
|
||||
scrollPan.allowedTouchTypes = []
|
||||
addGestureRecognizer(scrollPan)
|
||||
// Pencil squeeze / double-tap → the pen plane's barrel buttons (no-op while
|
||||
// `penEnabled` is false — PencilStream ignores interactions out of range).
|
||||
let pencilInteraction = UIPencilInteraction()
|
||||
pencilInteraction.delegate = pencil
|
||||
addInteraction(pencilInteraction)
|
||||
#endif
|
||||
backgroundColor = .black
|
||||
}
|
||||
@@ -810,32 +779,17 @@ final class StreamLayerUIView: UIView {
|
||||
private enum TouchKind { case down, move, up, cancel }
|
||||
|
||||
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
|
||||
/// the host cursor as an absolute mouse; a Pencil goes to the pen plane when the host
|
||||
/// supports it; everything else (direct finger — and the Pencil toward a pen-less host)
|
||||
/// is a host touch. Mixed batches are possible, so partition rather than branch on the
|
||||
/// first touch.
|
||||
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
|
||||
/// touch. Mixed batches are possible, so partition rather than branch on the first touch.
|
||||
private func route(_ touches: Set<UITouch>, event: UIEvent?, kind: TouchKind) {
|
||||
var fingers: Set<UITouch> = []
|
||||
var pencilTouches: Set<UITouch> = []
|
||||
for touch in touches {
|
||||
if touch.type == .indirectPointer {
|
||||
handleIndirectPointer(touch, event: event, kind: kind)
|
||||
} else if penEnabled, touch.type == .pencil {
|
||||
pencilTouches.insert(touch)
|
||||
} else {
|
||||
fingers.insert(touch)
|
||||
}
|
||||
}
|
||||
if !pencilTouches.isEmpty {
|
||||
let phase: PencilStream.Phase =
|
||||
switch kind {
|
||||
case .down: .down
|
||||
case .move: .move
|
||||
case .up: .up
|
||||
case .cancel: .cancel
|
||||
}
|
||||
pencil.touches(pencilTouches, event: event, phase: phase, in: self)
|
||||
}
|
||||
if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
|
||||
}
|
||||
|
||||
@@ -907,11 +861,8 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Button-less mouse/trackpad movement (no lock) → absolute cursor move — unless it is a
|
||||
/// hovering PENCIL (`zOffset > 0`) on a pen-capable host, which becomes in-range pen
|
||||
/// samples (hover preview with distance/tilt/azimuth) instead of a cursor move.
|
||||
/// Button-less mouse/trackpad movement (no lock) → absolute cursor move.
|
||||
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
|
||||
if penEnabled, pencil.maybeHover(recognizer, in: self) { return }
|
||||
switch recognizer.state {
|
||||
case .began, .changed:
|
||||
if let h = hostPoint(from: recognizer.location(in: self)) { onPointerMoveAbs?(h) }
|
||||
|
||||
@@ -119,25 +119,6 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||
}
|
||||
|
||||
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
|
||||
// The regression this pins: the "Controller type" setting reached the Hello only, and each
|
||||
// pad's arrival then re-declared the DETECTED kind — which the host honors over the
|
||||
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||
// (pf-client-core's `declared_kind` test is the same table.)
|
||||
XCTAssertEqual(
|
||||
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
|
||||
// Every physical pad in a mixed session follows the one explicit choice.
|
||||
for detected: PunktfunkConnection.GamepadType in [
|
||||
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
|
||||
] {
|
||||
XCTAssertEqual(
|
||||
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
|
||||
}
|
||||
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
|
||||
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
|
||||
}
|
||||
|
||||
func testTouchpadConversionCorners() {
|
||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||
|
||||
@@ -10,17 +10,9 @@ import {
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { definePlugin, routerHook } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
@@ -39,19 +31,7 @@ import {
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
});
|
||||
}
|
||||
import { ensureGamepadUiShortcut } from "./steam";
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
@@ -210,16 +190,6 @@ const QamPanel: FC = () => {
|
||||
{checking ? "Checking…" : "Check for updates"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Recreate library shortcut
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -55,29 +55,6 @@ declare const collectionStore:
|
||||
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
|
||||
| undefined;
|
||||
|
||||
// SteamUI's appStore indexes every registered app/shortcut by appId; a remembered appId whose
|
||||
// overview is gone was deleted out from under us (the user removed the library entry). We must
|
||||
// verify this because the remembered appId lives in Steam's CEF localStorage — which survives a
|
||||
// plugin UNINSTALL/REINSTALL — so a manually-deleted shortcut otherwise leaves a dangling appId
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
try {
|
||||
const get = appStore?.GetAppOverviewByAppID;
|
||||
if (!get) return true; // no way to verify — preserve the reuse path
|
||||
return get(appId) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
* after AddShortcut). Hides the stateful stream shortcut; keeps the gamepad-UI one visible. */
|
||||
function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||
@@ -222,10 +199,8 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
if (remembered != null) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
@@ -261,11 +236,8 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
if (appId != null) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
@@ -284,30 +256,6 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
export async function launchGamepadUi(): Promise<void> {
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
|
||||
@@ -547,7 +547,6 @@ impl AppModel {
|
||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
0, // client_caps: probe connect, nothing renders a cursor
|
||||
None, // launch: probe connect, no game
|
||||
pin,
|
||||
Some(identity),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! scenes.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
@@ -252,6 +252,18 @@ 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
|
||||
@@ -620,7 +632,6 @@ fn mock_library() -> (
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -523,9 +523,6 @@ async fn session(args: Args) -> Result<()> {
|
||||
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
|
||||
// path can be validated headlessly (check the host's monitor caps / ADD log line).
|
||||
display_hdr: punktfunk_core::client::display_hdr_env_override(),
|
||||
// No CLIENT_CAP_CURSOR: this headless tool renders nothing — advertising it would
|
||||
// just strip the pointer from the dumped bitstream.
|
||||
client_caps: 0,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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)
|
||||
@@ -18,16 +17,9 @@ 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`), so enrolling on either side makes the other work; this binary never
|
||||
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; 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: trust::device_name(),
|
||||
device_name: device_name(),
|
||||
deck: is_steam_deck(),
|
||||
};
|
||||
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
|
||||
@@ -251,6 +251,22 @@ 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,9 +5,8 @@
|
||||
//! 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 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.
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
|
||||
//! via the shell's headless `--pair`) makes this binary connect silently.
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
@@ -62,54 +61,6 @@ 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(':') {
|
||||
@@ -204,17 +155,9 @@ mod session_main {
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&settings.compositor)
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad: {
|
||||
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||
// session default for a pad that never declares one, so an explicit choice that
|
||||
// stopped here would be undone the moment a controller connected.
|
||||
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||
gamepad.set_kind_override(chosen);
|
||||
match chosen {
|
||||
GamepadPref::Auto => gamepad.auto_pref(),
|
||||
explicit => explicit,
|
||||
}
|
||||
gamepad: match GamepadPref::from_name(&settings.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
},
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
@@ -229,11 +172,6 @@ mod session_main {
|
||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||
// pump) pins one manually.
|
||||
display_hdr: None,
|
||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
||||
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
||||
// compositors only).
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
@@ -369,13 +307,6 @@ 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.
|
||||
@@ -433,14 +364,12 @@ 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\
|
||||
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||
client."
|
||||
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
@@ -472,7 +401,7 @@ mod session_main {
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
|
||||
@@ -56,7 +56,6 @@ pub fn run_speed_probe(
|
||||
decodable_codecs(),
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
0, // client_caps: probe connect, nothing renders a cursor
|
||||
None, // launch: no game
|
||||
pin,
|
||||
Some(identity),
|
||||
|
||||
@@ -30,12 +30,6 @@ pipewire = "0.9"
|
||||
libc = "0.2"
|
||||
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
|
||||
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
|
||||
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
|
||||
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
|
||||
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
|
||||
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
||||
@@ -49,9 +43,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Graphics_Direct3D_Fxc",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_HiDpi",
|
||||
|
||||
+15
-161
@@ -7,12 +7,10 @@
|
||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||
//! orchestrator).
|
||||
|
||||
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
|
||||
#![allow(dead_code)]
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -21,16 +19,9 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
#[cfg(target_os = "linux")]
|
||||
use pf_frame::DmabufFrame;
|
||||
|
||||
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
|
||||
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
|
||||
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
|
||||
/// freshest one.
|
||||
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
||||
/// over a bounded drop-oldest channel (never block the compositor).
|
||||
pub trait Capturer: Send {
|
||||
// ---- Frames -----------------------------------------------------------------------------
|
||||
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
|
||||
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
|
||||
// free-running tick.
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||
|
||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
||||
@@ -61,77 +52,17 @@ pub trait Capturer: Send {
|
||||
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
|
||||
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
|
||||
/// the compositor's publish instead of sampling at a free-running tick deletes the
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame — the
|
||||
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
|
||||
/// and then PEEKING its hand-off slot. Only called when
|
||||
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
|
||||
/// following `try_latest`.
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
|
||||
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
|
||||
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
|
||||
/// is `true`; errors surface at the following `try_latest`.
|
||||
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
|
||||
|
||||
// ---- Lifecycle --------------------------------------------------------------------------
|
||||
// Whether the capturer is being used right now, and whether it can still be used at all.
|
||||
|
||||
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
|
||||
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
|
||||
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
|
||||
///
|
||||
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
|
||||
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
|
||||
/// into the contract, and one the mailbox flush this now also does would not have shared.
|
||||
fn set_active(&mut self, _active: bool) {}
|
||||
|
||||
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
|
||||
/// across streams must consult before reusing one.
|
||||
///
|
||||
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
|
||||
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
|
||||
/// never returns to `Streaming` are all sticky, and each makes every subsequent
|
||||
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
|
||||
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
|
||||
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
|
||||
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
|
||||
/// have often already discarded.
|
||||
///
|
||||
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
|
||||
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
|
||||
fn is_alive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Cursor -----------------------------------------------------------------------------
|
||||
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
|
||||
|
||||
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
|
||||
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
||||
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
||||
/// produce NO new frame, so the frame-attached overlay would go stale on a static desktop.
|
||||
/// Default `None`: the Linux portal path attaches its cursor to frames instead.
|
||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
None
|
||||
}
|
||||
|
||||
/// LIVE cursor-render flip for a cursor-forward session (design/remote-desktop-sweep.md §8):
|
||||
/// `on = true` — the client draws the pointer, keep it OUT of the video; `on = false` —
|
||||
/// the capture mouse model, the pointer must be IN the video again. The Windows IDD
|
||||
/// capturer implements the composite side ITSELF (slot-copy + alpha-blended quad from the
|
||||
/// GDI poller) — a declared IddCx hardware cursor is irrevocable, so DWM can never be
|
||||
/// handed the job back. Called every encode tick (implementations cache; steady state is
|
||||
/// one compare). Default no-op: the Linux portal never bakes the pointer into frames —
|
||||
/// the encode loop blends its overlay instead.
|
||||
fn set_cursor_forward(&mut self, _on: bool) {}
|
||||
|
||||
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
||||
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
||||
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
|
||||
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
|
||||
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
|
||||
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
|
||||
/// non-gamescope capturer already has a cursor source.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
|
||||
|
||||
// ---- Stream properties ------------------------------------------------------------------
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
||||
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
||||
/// duration of a stream, `false` when it ends.
|
||||
fn set_active(&self, _active: bool) {}
|
||||
|
||||
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
||||
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
||||
@@ -152,18 +83,10 @@ pub trait Capturer: Send {
|
||||
1
|
||||
}
|
||||
|
||||
// ---- Host-initiated resize --------------------------------------------------------------
|
||||
// These two are ONE operation split in half and must be implemented together: a backend that
|
||||
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
|
||||
// implements `resize_output` without the identity leaves the caller no way to check that the
|
||||
// display it just reconfigured is still this capturer's. Both defaults decline.
|
||||
|
||||
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||
///
|
||||
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
|
||||
fn capture_target_id(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -174,25 +97,9 @@ pub trait Capturer: Send {
|
||||
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||
///
|
||||
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
|
||||
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake —
|
||||
/// the recovery half of a swap-chain bounce the descriptor poller cannot see: an
|
||||
/// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change,
|
||||
/// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain
|
||||
/// is recreated while this capturer keeps waiting on the old ring attachment — frames stop
|
||||
/// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never
|
||||
/// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot
|
||||
/// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the
|
||||
/// default) means the backend has no in-place ring recovery and the caller should treat the
|
||||
/// pipeline as unrecoverable in place.
|
||||
fn recreate_ring_in_place(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||
@@ -364,23 +271,6 @@ pub struct ZeroCopyPolicy {
|
||||
pub pyrowave_modifiers: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
|
||||
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
|
||||
///
|
||||
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
|
||||
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
|
||||
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
|
||||
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
|
||||
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
|
||||
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
|
||||
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
|
||||
///
|
||||
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
|
||||
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type GamescopeCursorTargets =
|
||||
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
true
|
||||
@@ -477,26 +367,6 @@ pub type FrameChannelSender = std::sync::Arc<
|
||||
dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync,
|
||||
>;
|
||||
|
||||
/// Delivery closure for the v5 hardware-cursor channel (`IOCTL_SET_CURSOR_CHANNEL`) — same
|
||||
/// facade contract as [`FrameChannelSender`]. `Some` also OPTS THE SESSION IN: the capturer
|
||||
/// creates + delivers the cursor section only when the host hands it a sender (the negotiated
|
||||
/// cursor-forward sessions), and the driver only declares the hardware cursor once that
|
||||
/// delivery lands — so a plain session keeps DWM's composited pointer untouched.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub type CursorChannelSender = std::sync::Arc<
|
||||
dyn Fn(&pf_driver_proto::control::SetCursorChannelRequest) -> Result<()> + Send + Sync,
|
||||
>;
|
||||
|
||||
/// The mid-stream cursor-render flip (`IOCTL_SET_CURSOR_FORWARD`, proto v6) as a host-facade
|
||||
/// closure — same contract as [`CursorChannelSender`]. `bool` = declare the IddCx hardware
|
||||
/// cursor (`true`) or stand it down (`false`; the host facade additionally forces the same-mode
|
||||
/// re-commit that actualises the OS's software-cursor default). The capturer drives this from
|
||||
/// its secure-desktop watch: UAC/Winlogon render only through the software-cursor path, so a
|
||||
/// path pinned to the hardware cursor never presents them (the 0.18.0 secure-desktop
|
||||
/// regression).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub type CursorForwardSender = std::sync::Arc<dyn Fn(bool) -> Result<()> + Send + Sync>;
|
||||
|
||||
// One-time PipeWire library init, shared by the video (portal) and audio capture threads.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pwinit;
|
||||
@@ -529,25 +399,15 @@ pub mod synthetic_nv12;
|
||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
|
||||
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
|
||||
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
|
||||
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
|
||||
/// (the one-way edge).
|
||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(
|
||||
anchored: bool,
|
||||
want_hdr: bool,
|
||||
want_metadata_cursor: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(
|
||||
anchored,
|
||||
want_hdr && !hdr_capture_failed(),
|
||||
want_metadata_cursor,
|
||||
policy,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
||||
@@ -564,7 +424,6 @@ pub fn open_virtual_output(
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
@@ -574,7 +433,6 @@ pub fn open_virtual_output(
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
@@ -592,8 +450,6 @@ pub fn open_idd_push(
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: FrameChannelSender,
|
||||
cursor_sender: Option<CursorChannelSender>,
|
||||
cursor_forward: Option<CursorForwardSender>,
|
||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||
idd_push::IddPushCapturer::open(
|
||||
target,
|
||||
@@ -603,8 +459,6 @@ pub fn open_idd_push(
|
||||
pyrowave,
|
||||
keepalive,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
+2204
-425
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,357 +0,0 @@
|
||||
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
|
||||
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
|
||||
//!
|
||||
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
|
||||
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
|
||||
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
|
||||
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
|
||||
//! for a control-plane caller.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||
pub fn gnome_hdr_monitor_active() -> bool {
|
||||
use ashpd::zbus;
|
||||
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
|
||||
// properties.
|
||||
type Mode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type Monitor = (
|
||||
(String, String, String, String),
|
||||
Vec<Mode>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<(String, String, String, String)>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type State = (
|
||||
u32,
|
||||
Vec<Monitor>,
|
||||
Vec<LogicalMonitor>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
let probe = || -> Result<bool> {
|
||||
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
|
||||
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime")?;
|
||||
rt.block_on(async {
|
||||
let conn = zbus::Connection::session().await.context("session bus")?;
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"GetCurrentState",
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")?;
|
||||
let (_serial, monitors, _logical, _props): State = reply
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||
props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||
}))
|
||||
})
|
||||
};
|
||||
match probe() {
|
||||
Ok(hdr) => hdr,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
|
||||
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
|
||||
/// Without it — the session's encode path has no compositing stage for a metadata cursor
|
||||
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
|
||||
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
|
||||
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
|
||||
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||
async fn choose_cursor_mode(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
want_metadata: bool,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match proxy.available_cursor_modes().await {
|
||||
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||
if want_metadata {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
|
||||
composite a metadata cursor)"
|
||||
);
|
||||
}
|
||||
CursorMode::Embedded
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
|
||||
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
|
||||
(only CPU-path frames will composite it)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) => {
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
|
||||
);
|
||||
CursorMode::Hidden
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
|
||||
);
|
||||
CursorMode::Embedded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
|
||||
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
|
||||
pub(super) fn portal_thread(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus connection's background reader must be pumped
|
||||
// continuously across the create_session → select_sources → start handshake, or the
|
||||
// portal reports "Invalid session". (A current-thread runtime starves it.)
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// Only MONITOR is offered by the wlroots backend
|
||||
// (AvailableSourceTypes=1); requesting unsupported types
|
||||
// invalidates the session.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser cancelled? portal misconfigured?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
|
||||
// capture; the cast is torn down when the connection drops (ashpd's `Session`
|
||||
// has no `Drop`) — which now happens when this park returns, not at process exit.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
|
||||
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
|
||||
// compositor-side session is really gone (see `PortalSession::drop`).
|
||||
drop(rt);
|
||||
}
|
||||
|
||||
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
|
||||
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
|
||||
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
|
||||
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
|
||||
pub(super) fn portal_thread_remote_desktop(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let remote = RemoteDesktop::new()
|
||||
.await
|
||||
.context("connect RemoteDesktop portal")?;
|
||||
let screencast = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = remote
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create RemoteDesktop session")?;
|
||||
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
|
||||
// (input injection runs its own), but selecting devices is what makes `start` the
|
||||
// RemoteDesktop grant the kde-authorized bypass covers.
|
||||
remote
|
||||
.select_devices(
|
||||
&session,
|
||||
SelectDevicesOptions::default()
|
||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_devices")?
|
||||
.response()
|
||||
.context("select_devices rejected")?;
|
||||
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
|
||||
screencast
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type?)")?;
|
||||
let streams = remote
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start RemoteDesktop+ScreenCast")?
|
||||
.response()
|
||||
.context("start response (grant not pre-authorized / headless dialog?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no screencast streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = screencast
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep the proxies + session (and their zbus connection) alive for the capture, until
|
||||
// the capturer's `Drop` fires the quit channel.
|
||||
let _keep_alive = (&remote, &screencast, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// See `portal_thread`: drop the runtime before the caller's completion signal.
|
||||
drop(rt);
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
|
||||
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
|
||||
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
|
||||
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
|
||||
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
|
||||
//! without a compositor.
|
||||
|
||||
use super::PixelFormat;
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
|
||||
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
|
||||
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
|
||||
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct CursorState {
|
||||
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
|
||||
visible: bool,
|
||||
/// Top-left where the bitmap is drawn = reported position − hotspot.
|
||||
x: i32,
|
||||
y: i32,
|
||||
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
|
||||
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
|
||||
rgba: Arc<Vec<u8>>,
|
||||
bw: u32,
|
||||
bh: u32,
|
||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||
serial: u64,
|
||||
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
||||
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
||||
hot_x: i32,
|
||||
hot_y: i32,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
|
||||
/// The encode loop strips invisible overlays before any blend path sees the frame.
|
||||
/// Cheap: clones an `Arc` + a few scalars.
|
||||
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||
if self.rgba.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(pf_frame::CursorOverlay {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
w: self.bw,
|
||||
h: self.bh,
|
||||
rgba: self.rgba.clone(),
|
||||
serial: self.serial,
|
||||
hot_x: self.hot_x.max(0) as u32,
|
||||
hot_y: self.hot_y.max(0) as u32,
|
||||
visible: self.visible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
|
||||
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
|
||||
/// 4-byte formats are read as RGBA.
|
||||
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
match vfmt {
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
|
||||
_ => (s[0], s[1], s[2], s[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
||||
// are ALL producer-written, and without a bound against the actual region they drive
|
||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||
if meta.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
||||
return;
|
||||
}
|
||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
(*cur).position.x,
|
||||
(*cur).position.y,
|
||||
(*cur).hotspot.x,
|
||||
(*cur).hotspot.y,
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
cursor.hot_y = hot_y;
|
||||
if bmp_off == 0 {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
let bmp_off = bmp_off as usize;
|
||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
||||
Some(end) if end <= region_size => {}
|
||||
_ => return,
|
||||
}
|
||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
||||
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
|
||||
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
|
||||
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
|
||||
// the previous field reads through an aligned `*const` asserted an invariant the code never
|
||||
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
|
||||
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
|
||||
let (vfmt, bw, bh, stride, pix_off) = (
|
||||
bmp.format,
|
||||
bmp.size.width,
|
||||
bmp.size.height,
|
||||
bmp.stride.max(0) as usize,
|
||||
bmp.offset as usize,
|
||||
);
|
||||
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
|
||||
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
|
||||
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
|
||||
return;
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
|
||||
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
|
||||
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
|
||||
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
|
||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||
for y in 0..bh as usize {
|
||||
for x in 0..bw as usize {
|
||||
let so = y * stride + x * 4;
|
||||
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
|
||||
let d = (y * bw as usize + x) * 4;
|
||||
rgba[d] = r;
|
||||
rgba[d + 1] = g;
|
||||
rgba[d + 2] = b;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
}
|
||||
cursor.rgba = Arc::new(rgba);
|
||||
cursor.bw = bw;
|
||||
cursor.bh = bh;
|
||||
cursor.serial = cursor.serial.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
|
||||
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
|
||||
/// any of the arithmetic overflows.
|
||||
///
|
||||
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
|
||||
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
|
||||
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
|
||||
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
|
||||
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
|
||||
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
|
||||
///
|
||||
/// `len()` of the returned range is EXACTLY `stride·(bh−1) + row`: the last row contributes only its
|
||||
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
|
||||
/// accepted rather than rejected by a padding byte that is never read.
|
||||
///
|
||||
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
|
||||
/// compositor.
|
||||
fn bitmap_extent(
|
||||
bmp_off: usize,
|
||||
pix_off: usize,
|
||||
stride: usize,
|
||||
row: usize,
|
||||
bh: usize,
|
||||
region_size: usize,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
if bh == 0 || row == 0 || stride < row {
|
||||
return None;
|
||||
}
|
||||
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
|
||||
let start = bmp_off.checked_add(pix_off)?;
|
||||
let end = start.checked_add(span)?;
|
||||
(end <= region_size).then_some(start..end)
|
||||
}
|
||||
|
||||
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
|
||||
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
|
||||
/// the CPU de-pad path anyway).
|
||||
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
|
||||
Some(match fmt {
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
|
||||
PixelFormat::Rgb => (0, 1, 2, 3),
|
||||
PixelFormat::Bgr => (2, 1, 0, 3),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
|
||||
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
|
||||
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
|
||||
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
|
||||
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
|
||||
pub(super) fn composite_cursor_rgb10(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
r_shift: u32,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
|
||||
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
|
||||
let (sr, sg, sb) = (
|
||||
up10(cursor.rgba[s]),
|
||||
up10(cursor.rgba[s + 1]),
|
||||
up10(cursor.rgba[s + 2]),
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * 4;
|
||||
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
|
||||
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
|
||||
let dr = blend((px >> r_shift) & 0x3ff, sr);
|
||||
let dg = blend((px >> 10) & 0x3ff, sg);
|
||||
let db = blend((px >> b_shift) & 0x3ff, sb);
|
||||
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
|
||||
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||
pub(super) fn composite_cursor(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
fmt: PixelFormat,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
if !cursor.visible || cursor.rgba.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
|
||||
match fmt {
|
||||
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
|
||||
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
|
||||
_ => {}
|
||||
}
|
||||
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let (sr, sg, sb) = (
|
||||
cursor.rgba[s] as u32,
|
||||
cursor.rgba[s + 1] as u32,
|
||||
cursor.rgba[s + 2] as u32,
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * bpp;
|
||||
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
|
||||
tight[di + ri] = blend(tight[di + ri], sr);
|
||||
tight[di + gi] = blend(tight[di + gi], sg);
|
||||
tight[di + bi] = blend(tight[di + bi], sb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
|
||||
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for _ in 0..w * h {
|
||||
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
|
||||
}
|
||||
CursorState {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
rgba: Arc::new(px),
|
||||
bw: w,
|
||||
bh: h,
|
||||
serial: 1,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_accepts_a_bitmap_that_fits() {
|
||||
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
|
||||
// …and the same bitmap behind a header + pixel offset.
|
||||
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
|
||||
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
|
||||
// region must be accepted: the trailing stride padding is never read.
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_anything_past_the_region() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
|
||||
// An offset alone can push it out.
|
||||
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
|
||||
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
|
||||
// A region of zero accepts nothing.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
|
||||
}
|
||||
|
||||
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
|
||||
#[test]
|
||||
fn bitmap_extent_survives_hostile_arithmetic() {
|
||||
// stride × (bh-1) overflows.
|
||||
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
|
||||
// span + row overflows. Needs ≥2 rows so `stride·(bh−1)` is already at the ceiling: with a
|
||||
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
|
||||
// range — which is correct rather than a miss, since the caller has already capped `bw` at
|
||||
// 1024 and `row` is therefore ≤ 4096.
|
||||
assert_eq!(
|
||||
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
|
||||
None
|
||||
);
|
||||
// bmp_off + pix_off overflows.
|
||||
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
|
||||
// start + span overflows.
|
||||
assert_eq!(
|
||||
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
|
||||
None
|
||||
);
|
||||
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
|
||||
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_degenerate_geometry() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
|
||||
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
|
||||
}
|
||||
|
||||
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
|
||||
|
||||
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
|
||||
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
|
||||
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
|
||||
let i = (y * w + x) * bpp;
|
||||
(buf[i + ri], buf[i + gi], buf[i + bi])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
|
||||
for fmt in [
|
||||
PixelFormat::Bgrx,
|
||||
PixelFormat::Bgra,
|
||||
PixelFormat::Rgbx,
|
||||
PixelFormat::Rgba,
|
||||
PixelFormat::Rgb,
|
||||
PixelFormat::Bgr,
|
||||
] {
|
||||
let bpp = dst_offsets(fmt).unwrap().3;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mut buf = vec![0u8; w * h * bpp];
|
||||
// Opaque pure red at (1, 1).
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
|
||||
// Nothing else moved.
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
|
||||
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
|
||||
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
|
||||
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
|
||||
);
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
|
||||
// Bottom-right: only the top-left quarter lands, at (3, 3).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
|
||||
// Fully outside in each direction: the frame is untouched.
|
||||
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_hidden_cursors_draw_nothing() {
|
||||
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
|
||||
// Alpha 0 — every pixel skipped.
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// `visible: false` — the whole blit is skipped.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.visible = false;
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// No bitmap yet — likewise.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.rgba = Arc::new(Vec::new());
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_alpha_blends_toward_the_destination() {
|
||||
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
|
||||
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
|
||||
let mut buf = vec![255u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
|
||||
}
|
||||
|
||||
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
|
||||
#[test]
|
||||
fn unsupported_layouts_are_declined() {
|
||||
assert!(dst_offsets(PixelFormat::Nv12).is_none());
|
||||
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::Nv12,
|
||||
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
|
||||
}
|
||||
|
||||
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
|
||||
|
||||
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
|
||||
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
|
||||
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_round_trips_an_untouched_pixel() {
|
||||
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
|
||||
// (including the top two bits, which are alpha and must survive the repack).
|
||||
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
|
||||
let src = pack_x2rgb10(r, g, b);
|
||||
let mut buf = src.to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
|
||||
);
|
||||
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
|
||||
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
|
||||
assert_eq!((v >> 10) & 0x3ff, 0, "G");
|
||||
assert_eq!(v & 0x3ff, 0, "B");
|
||||
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
|
||||
|
||||
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Bgr10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
|
||||
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_clips_like_the_8bit_one() {
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
|
||||
let before = buf.clone();
|
||||
// Entirely off-frame.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert_eq!(buf, before);
|
||||
// Straddling the top-left corner: only (0, 0) is written.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
|
||||
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
|
||||
assert_eq!((p0 >> 20) & 0x3ff, 1023);
|
||||
assert_eq!((p1 >> 20) & 0x3ff, 0);
|
||||
}
|
||||
|
||||
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn each_bitmap_format_is_decoded_to_straight_rgba() {
|
||||
let s = [1u8, 2, 3, 4];
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
|
||||
(1, 2, 3, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
|
||||
(3, 2, 1, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
|
||||
(2, 3, 4, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
|
||||
(4, 3, 2, 1)
|
||||
);
|
||||
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
|
||||
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
|
||||
}
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
|
||||
//! `Pod` serializer they all end in.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
|
||||
//! these builders put in a pod is what the compositor intersects against, and a missing property is
|
||||
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
|
||||
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use spa::param::video::VideoFormat;
|
||||
|
||||
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
|
||||
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pod")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||
pub(super) fn build_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
modifiers: &[u64],
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
if format == VideoFormat::NV12 {
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||
)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||
)),
|
||||
});
|
||||
}
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Enum {
|
||||
default: modifiers[0] as i64,
|
||||
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
|
||||
},
|
||||
),
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
|
||||
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
|
||||
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
|
||||
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
|
||||
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
|
||||
///
|
||||
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
|
||||
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
|
||||
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
|
||||
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
|
||||
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
|
||||
/// regardless of the negotiated format.
|
||||
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
|
||||
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
|
||||
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
|
||||
/// then emits no such constant and the host fails to compile there, even though the code never
|
||||
/// runs on those systems (the HDR path needs GNOME 50+).
|
||||
///
|
||||
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
|
||||
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
|
||||
/// together, so the value is identical on every libspa that has the symbol at all. On one that
|
||||
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
|
||||
/// SDR — the same outcome as not offering HDR.
|
||||
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
|
||||
|
||||
pub(super) fn build_hdr_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
|
||||
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
|
||||
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
pw::spa::param::format::MediaType::Video
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
pw::spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
// Offer the layouts the encoder can map to an NVENC input format. wlroots
|
||||
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
|
||||
// these are requested, so negotiation fails loudly rather than handing us a
|
||||
// format we'd misinterpret.
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFormat,
|
||||
Choice,
|
||||
Enum,
|
||||
Id,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::BGR,
|
||||
VideoFormat::RGBx,
|
||||
VideoFormat::BGRx,
|
||||
VideoFormat::RGBA,
|
||||
VideoFormat::BGRA,
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
|
||||
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
|
||||
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
|
||||
/// without this bit the buffer-type intersection is empty and the link silently stalls in
|
||||
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
|
||||
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
|
||||
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
|
||||
/// render — so the frame is complete and current by construction. This is the only race-free
|
||||
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
|
||||
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
|
||||
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
|
||||
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
|
||||
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
|
||||
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
|
||||
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
|
||||
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
|
||||
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
|
||||
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
|
||||
fn meta_size(w: u32, h: u32) -> i32 {
|
||||
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
|
||||
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
|
||||
+ (w as usize * h as usize * 4)) as i32
|
||||
}
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
|
||||
id: pw::spa::param::ParamType::Meta.as_raw(),
|
||||
properties: vec![
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_type,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
|
||||
},
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_size,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
// The max must cover the producer's offer or the Meta param silently
|
||||
// fails to negotiate and NO buffer ever carries the meta region:
|
||||
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
|
||||
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
|
||||
// intersection empty, which cost the whole Linux cursor channel
|
||||
// on-glass. 1024² is headroom, not an allocation: the negotiated
|
||||
// region follows the producer's value.
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: meta_size(64, 64),
|
||||
min: meta_size(1, 1),
|
||||
max: meta_size(1024, 1024),
|
||||
},
|
||||
),
|
||||
)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
|
||||
///
|
||||
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
|
||||
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
|
||||
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
|
||||
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
|
||||
fn buffers_data_type(pod: &[u8]) -> i32 {
|
||||
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
|
||||
let at = pod
|
||||
.windows(4)
|
||||
.position(|w| w == key)
|
||||
.expect("dataType key present in the Buffers pod");
|
||||
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
|
||||
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
|
||||
assert_eq!(
|
||||
word(at + 12),
|
||||
spa::sys::SPA_TYPE_Int,
|
||||
"dataType's value pod should be an Int"
|
||||
);
|
||||
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
|
||||
}
|
||||
|
||||
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
|
||||
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
|
||||
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
|
||||
|
||||
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
|
||||
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
|
||||
/// format intersection and the BUFFER intersection is then empty (a link stuck in
|
||||
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
|
||||
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
|
||||
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
|
||||
/// regardless of the negotiated 10-bit format.
|
||||
#[test]
|
||||
fn each_buffers_pod_requests_exactly_its_own_data_types() {
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_mappable_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD | DMABUF,
|
||||
"the CPU path must accept mappable dmabufs too"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_shm_only_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD,
|
||||
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_dmabuf_buffers().unwrap()),
|
||||
DMABUF,
|
||||
"the zero-copy/HDR path must exclude SHM"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
|
||||
/// emitted a malformed object would fail only at negotiation, on a live compositor.
|
||||
#[test]
|
||||
fn every_pod_round_trips_through_pod_from_bytes() {
|
||||
let mut pods: Vec<(&str, Vec<u8>)> = vec![
|
||||
("mappable buffers", build_mappable_buffers().unwrap()),
|
||||
("shm-only buffers", build_shm_only_buffers().unwrap()),
|
||||
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
|
||||
("cursor meta", build_cursor_meta_param().unwrap()),
|
||||
(
|
||||
"default format",
|
||||
serialize_pod(build_default_format_obj(None)).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf BGRx",
|
||||
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf NV12",
|
||||
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xRGB",
|
||||
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xBGR",
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
|
||||
),
|
||||
];
|
||||
for (name, bytes) in &mut pods {
|
||||
assert!(!bytes.is_empty(), "{name} serialized to nothing");
|
||||
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
|
||||
assert!(
|
||||
spa::pod::Pod::from_bytes(bytes).is_some(),
|
||||
"{name} did not parse back as a pod"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
|
||||
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
|
||||
/// 10-bit stream (or nothing at all).
|
||||
#[test]
|
||||
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
|
||||
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
|
||||
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
|
||||
for (name, key) in [
|
||||
(
|
||||
"transferFunction",
|
||||
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
),
|
||||
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
|
||||
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
|
||||
] {
|
||||
assert!(
|
||||
pod.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"{fmt:?} pod is missing {name}"
|
||||
);
|
||||
}
|
||||
// The PQ id and BT.2020 id must both appear as values.
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry the PQ transfer id"
|
||||
);
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry BT.2020 primaries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
|
||||
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
|
||||
#[test]
|
||||
fn only_the_nv12_offer_pins_the_colour_matrix() {
|
||||
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
|
||||
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
|
||||
for (name, key) in [
|
||||
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
|
||||
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
|
||||
] {
|
||||
assert!(
|
||||
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"NV12 offer is missing {name}"
|
||||
);
|
||||
assert!(
|
||||
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"packed-RGB offer should not pin {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
|
||||
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
|
||||
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
|
||||
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
|
||||
/// the HDR offer with the wrong transfer function.
|
||||
///
|
||||
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
|
||||
/// so this never reintroduces the compile failure it exists to prevent.
|
||||
#[test]
|
||||
fn pq_transfer_id_matches_libspa() {
|
||||
assert_eq!(
|
||||
super::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,957 +0,0 @@
|
||||
//! XFixes cursor source for the gamescope capture path (remote-desktop-sweep Phase C).
|
||||
//!
|
||||
//! gamescope draws the pointer on a DRM hardware-cursor plane and its `paint_pipewire()`
|
||||
//! deliberately excludes the cursor from the frame it feeds its built-in PipeWire node — so
|
||||
//! `SPA_META_Cursor` never arrives and the ordinary [`cursor_live`](super::PortalCapturer) slot
|
||||
//! stays empty (a KWin/GNOME session gets its cursor from that meta; gamescope can't embed one
|
||||
//! either, its `set_hw_cursor` is inert). We instead read the pointer from gamescope's nested
|
||||
//! Xwayland via X11 — the trick Sunshine uses — and publish a [`CursorOverlay`] into that same
|
||||
//! slot, so the encoder blend composites the pointer into the video exactly like the portal path.
|
||||
//!
|
||||
//! **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 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.
|
||||
//!
|
||||
//! 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.
|
||||
//! * **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, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use pf_frame::CursorOverlay;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::errors::ReplyError;
|
||||
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
||||
use x11rb::protocol::xproto::{
|
||||
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply,
|
||||
Window,
|
||||
};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::{DefaultStream, RustConnection};
|
||||
|
||||
use crate::GamescopeCursorTargets;
|
||||
|
||||
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
|
||||
///
|
||||
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
|
||||
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
|
||||
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
|
||||
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
|
||||
/// environ. That is why the primary path parses the cookie itself and never touches the
|
||||
/// environment.
|
||||
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
|
||||
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
|
||||
|
||||
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
|
||||
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
|
||||
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
|
||||
/// pointer and costs one cheap socket probe per known display.
|
||||
const REDISCOVER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
||||
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
||||
/// 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 waits — bounded — for it
|
||||
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
|
||||
pub(super) struct XFixesCursorSource {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
|
||||
done: std::sync::mpsc::Receiver<()>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl XFixesCursorSource {
|
||||
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
|
||||
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
|
||||
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
|
||||
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
|
||||
///
|
||||
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
|
||||
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
|
||||
/// starts before the game converges instead of being cursorless for the session.
|
||||
pub(super) fn spawn(
|
||||
targets: GamescopeCursorTargets,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) -> Option<Self> {
|
||||
// First pass on the caller's thread: the common case connects here, so the log line below
|
||||
// reports the real state instead of "starting…".
|
||||
let mut displays = Vec::new();
|
||||
rediscover(&mut displays, &targets, true);
|
||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||
if displays.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
|
||||
Xwayland appears when it launches)",
|
||||
REDISCOVER.as_secs()
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
displays = ?names,
|
||||
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));
|
||||
let stop_worker = Arc::clone(&stop);
|
||||
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-gs-cursor".into())
|
||||
.spawn(move || {
|
||||
run(displays, slot, stop_worker, targets, frame_size);
|
||||
let _ = done_tx.send(());
|
||||
})
|
||||
.ok()?;
|
||||
Some(XFixesCursorSource {
|
||||
stop,
|
||||
done: done_rx,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XFixesCursorSource {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
|
||||
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
|
||||
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
|
||||
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
|
||||
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
|
||||
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
|
||||
if let Some(j) = self.join.take() {
|
||||
if joinable {
|
||||
let _ = j.join(); // returns at once: `done` already fired
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
|
||||
detaching it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
|
||||
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
|
||||
/// their shape cache and last position survive.
|
||||
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
|
||||
for (dpy, xauth) in targets() {
|
||||
let existing = displays.iter().position(|d| d.name == dpy);
|
||||
if existing.is_some_and(|i| !displays[i].dead) {
|
||||
continue;
|
||||
}
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root, root_size, feedback)) => {
|
||||
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
|
||||
match existing {
|
||||
Some(i) => {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
|
||||
displays[i] = d;
|
||||
}
|
||||
None => {
|
||||
if !first {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
|
||||
}
|
||||
displays.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
|
||||
// `--xwayland-count` reports but that never comes up.
|
||||
Err(e) if first => tracing::warn!(
|
||||
dpy = %dpy, error = %e,
|
||||
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
|
||||
),
|
||||
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) 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).
|
||||
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
|
||||
|
||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
|
||||
|
||||
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
||||
conn.xfixes_query_version(5, 0)
|
||||
.map_err(ReplyError::from)
|
||||
.and_then(|c| c.reply())
|
||||
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
||||
|
||||
let screen = conn
|
||||
.setup()
|
||||
.roots
|
||||
.get(screen_num)
|
||||
.ok_or_else(|| format!("no X screen {screen_num}"))?;
|
||||
let root = screen.root;
|
||||
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
|
||||
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
|
||||
// Free here — the setup reply is already parsed.
|
||||
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
|
||||
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
||||
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
||||
.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();
|
||||
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||
Ok((conn, root, root_size, (feedback_atom, feedback)))
|
||||
}
|
||||
|
||||
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
|
||||
/// environment.
|
||||
///
|
||||
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
|
||||
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
|
||||
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
|
||||
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
|
||||
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
|
||||
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
|
||||
/// sharing it would be the wrong layer AND would still not fix `getenv`.
|
||||
///
|
||||
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
|
||||
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
|
||||
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
|
||||
/// unexpected layout, or an auth family whose entry we decline to guess at).
|
||||
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
|
||||
let Some(path) = xauthority else {
|
||||
// No per-display cookie file to inject: the ambient environment is already what this
|
||||
// connect should use, so there is nothing to swap and nothing to parse.
|
||||
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
|
||||
};
|
||||
match mit_magic_cookie(path, dpy) {
|
||||
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path, error = %e,
|
||||
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
|
||||
),
|
||||
},
|
||||
None => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path,
|
||||
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
|
||||
XAUTHORITY env swap"
|
||||
),
|
||||
}
|
||||
connect_via_env_swap(dpy, path)
|
||||
}
|
||||
|
||||
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
|
||||
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
|
||||
fn connect_with_cookie(
|
||||
dpy: &str,
|
||||
auth_name: Vec<u8>,
|
||||
auth_data: Vec<u8>,
|
||||
) -> Result<(RustConnection, usize), String> {
|
||||
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
|
||||
.map_err(|e| format!("parse display {dpy}: {e}"))?;
|
||||
let screen = usize::from(parsed.screen);
|
||||
let mut stream = None;
|
||||
let mut last_err = None;
|
||||
for addr in parsed.connect_instruction() {
|
||||
match DefaultStream::connect(&addr) {
|
||||
Ok((s, _peer)) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
let stream = stream.ok_or_else(|| match last_err {
|
||||
Some(e) => format!("connect: {e}"),
|
||||
None => "connect: no usable address".to_string(),
|
||||
})?;
|
||||
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
|
||||
.map(|c| (c, screen))
|
||||
.map_err(|e| format!("setup: {e}"))
|
||||
}
|
||||
|
||||
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
|
||||
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
|
||||
/// is a fallback and not the path taken.
|
||||
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
std::env::set_var("XAUTHORITY", xauthority);
|
||||
let out = RustConnection::connect(Some(dpy));
|
||||
match prev {
|
||||
Some(p) => std::env::set_var("XAUTHORITY", p),
|
||||
None => std::env::remove_var("XAUTHORITY"),
|
||||
}
|
||||
out.map_err(|e| format!("connect: {e}"))
|
||||
}
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
|
||||
///
|
||||
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
|
||||
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
|
||||
/// `number` matches any display.
|
||||
///
|
||||
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
|
||||
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
|
||||
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
|
||||
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
|
||||
/// the other hand, is exactly the sort of detail that would rot.
|
||||
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let want = display_number(dpy);
|
||||
// An entry whose `number` is empty matches any display — only used if no exact match is found.
|
||||
let mut wildcard = None;
|
||||
let mut p = 0usize;
|
||||
'entries: while p + 2 <= bytes.len() {
|
||||
p += 2; // family
|
||||
let mut fields: [&[u8]; 4] = [&[]; 4];
|
||||
for f in fields.iter_mut() {
|
||||
let Some(lb) = bytes.get(p..p + 2) else {
|
||||
break 'entries; // truncated — keep whatever we already found
|
||||
};
|
||||
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
|
||||
p += 2;
|
||||
let Some(v) = bytes.get(p..p + len) else {
|
||||
break 'entries;
|
||||
};
|
||||
*f = v;
|
||||
p += len;
|
||||
}
|
||||
let [_address, number, name, data] = fields;
|
||||
if name != MIT_MAGIC_COOKIE_1 {
|
||||
continue;
|
||||
}
|
||||
if want.as_deref().is_some_and(|w| number == w) {
|
||||
return Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
if number.is_empty() && wildcard.is_none() {
|
||||
wildcard = Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
}
|
||||
wildcard
|
||||
}
|
||||
|
||||
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
|
||||
/// yield `b"2"`. `None` when there is no numeric display component to match on.
|
||||
fn display_number(dpy: &str) -> Option<Vec<u8>> {
|
||||
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
|
||||
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
struct XDisplay {
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
|
||||
/// necessarily the captured frame's (see [`scale_to_frame`]).
|
||||
root_size: (u16, u16),
|
||||
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
||||
last_pos: Option<(i32, i32)>,
|
||||
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
||||
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,
|
||||
root_size: (u16, u16),
|
||||
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||
) -> Self {
|
||||
XDisplay {
|
||||
name,
|
||||
conn,
|
||||
root,
|
||||
root_size,
|
||||
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.
|
||||
#[derive(Default)]
|
||||
struct Shape {
|
||||
/// Straight-alpha RGBA (`w*h*4`, bytes R,G,B,A); empty before the first image arrives.
|
||||
rgba: Arc<Vec<u8>>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
hot_x: u32,
|
||||
hot_y: u32,
|
||||
/// XFixes' own per-display cursor serial — bumps on every shape change.
|
||||
serial: u64,
|
||||
/// A hidden pointer arrives as an all-transparent image; kept so a position-only tick preserves
|
||||
/// the last known visibility.
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
/// Map a root-space pointer position into FRAME space.
|
||||
///
|
||||
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
|
||||
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
|
||||
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
|
||||
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
|
||||
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
|
||||
/// case the position passes through unscaled — the same as before, and correct for the common case
|
||||
/// where the two spaces agree.
|
||||
///
|
||||
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
|
||||
/// the pointer lands in the right place but is drawn at root scale.
|
||||
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
|
||||
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
|
||||
let (fw, fh) = frame;
|
||||
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
|
||||
return (x, y);
|
||||
}
|
||||
(
|
||||
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
|
||||
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
|
||||
)
|
||||
}
|
||||
|
||||
fn run(
|
||||
mut displays: Vec<XDisplay>,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
targets: GamescopeCursorTargets,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut last_discover = std::time::Instant::now();
|
||||
let mut warned_scale = false;
|
||||
let mut active = 0usize;
|
||||
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
||||
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
||||
// displays, so switching could reuse a number and the encoder would keep the old texture).
|
||||
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) {
|
||||
// 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();
|
||||
}
|
||||
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
|
||||
if last_discover.elapsed() >= REDISCOVER {
|
||||
last_discover = std::time::Instant::now();
|
||||
rediscover(&mut displays, &targets, false);
|
||||
}
|
||||
|
||||
// 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. 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(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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
let moved = d.last_pos.is_some_and(|lp| lp != pos);
|
||||
d.last_pos = Some(pos);
|
||||
if moved {
|
||||
if i == active {
|
||||
active_moved = true;
|
||||
} else if other_moved.is_none() {
|
||||
other_moved = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // pointer on another screen — keep the last position.
|
||||
Err(_) => d.dead = true,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
None => {
|
||||
std::thread::sleep(POLL); // all connections dead — idle until Drop.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fetch the active display's shape if a CursorNotify (or a focus switch) left it stale.
|
||||
if displays[active].need_shape {
|
||||
match fetch_cursor_image(&displays[active].conn) {
|
||||
Ok(img) => {
|
||||
update_shape(&mut displays[active].shape, &img);
|
||||
displays[active].need_shape = false;
|
||||
}
|
||||
Err(e) => {
|
||||
if !warned_image {
|
||||
warned_image = true;
|
||||
tracing::warn!(error = %e, "gamescope cursor: GetCursorImage failed — retrying");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
|
||||
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
|
||||
let packed = frame_size.load(Ordering::Relaxed);
|
||||
let frame = ((packed >> 32) as u32, packed as u32);
|
||||
if !warned_scale
|
||||
&& frame.0 != 0
|
||||
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
|
||||
{
|
||||
warned_scale = true;
|
||||
tracing::warn!(
|
||||
dpy = %d.name,
|
||||
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
|
||||
negotiated = %format!("{}x{}", frame.0, frame.1),
|
||||
"gamescope cursor: the nested root and the captured frame are different sizes \
|
||||
(gamescope -w/-h vs -W/-H) — scaling the pointer POSITION into frame space; the \
|
||||
cursor bitmap stays at root scale"
|
||||
);
|
||||
}
|
||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||
(Some(pos), false) => {
|
||||
let (px, py) = scale_to_frame(pos, d.root_size, frame);
|
||||
let key = (active, d.shape.serial);
|
||||
if key != last_key {
|
||||
out_serial += 1;
|
||||
last_key = key;
|
||||
}
|
||||
Some(CursorOverlay {
|
||||
// Top-left = pointer position − hotspot (the overlay contract).
|
||||
x: px - d.shape.hot_x as i32,
|
||||
y: py - d.shape.hot_y as i32,
|
||||
w: d.shape.w,
|
||||
h: d.shape.h,
|
||||
rgba: Arc::clone(&d.shape.rgba),
|
||||
serial: out_serial,
|
||||
hot_x: d.shape.hot_x,
|
||||
hot_y: d.shape.hot_y,
|
||||
visible: drawn,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Ok(mut s) = slot.lock() {
|
||||
*s = overlay;
|
||||
}
|
||||
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let visible =
|
||||
img.width > 0 && img.height > 0 && img.cursor_image.iter().any(|&p| (p >> 24) & 0xff != 0);
|
||||
if visible {
|
||||
shape.rgba = Arc::new(argb_premul_to_straight_rgba(&img.cursor_image));
|
||||
shape.w = u32::from(img.width);
|
||||
shape.h = u32::from(img.height);
|
||||
shape.hot_x = u32::from(img.xhot);
|
||||
shape.hot_y = u32::from(img.yhot);
|
||||
}
|
||||
shape.visible = visible;
|
||||
shape.serial = u64::from(img.cursor_serial);
|
||||
}
|
||||
|
||||
/// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is
|
||||
/// `ReplyError` which is `From<ConnectionError>`), so the request `?` converts into the reply error.
|
||||
fn fetch_cursor_image(conn: &RustConnection) -> Result<GetCursorImageReply, ReplyError> {
|
||||
conn.xfixes_get_cursor_image()?.reply()
|
||||
}
|
||||
|
||||
fn fetch_pointer(conn: &RustConnection, root: Window) -> Result<QueryPointerReply, ReplyError> {
|
||||
conn.query_pointer(root)?.reply()
|
||||
}
|
||||
|
||||
/// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor
|
||||
/// convention). The overlay + both blend paths want **straight** alpha RGBA (R,G,B,A bytes), like
|
||||
/// the `SPA_META_Cursor` path — so un-premultiply here. (If on-glass shows over-bright fringes the
|
||||
/// source wasn't premultiplied after all; drop the divide.)
|
||||
fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(argb.len() * 4);
|
||||
for &px in argb {
|
||||
let a = (px >> 24) & 0xff;
|
||||
let r = (px >> 16) & 0xff;
|
||||
let g = (px >> 8) & 0xff;
|
||||
let b = px & 0xff;
|
||||
let (r, g, b) = match a {
|
||||
0 => (0, 0, 0),
|
||||
255 => (r, g, b),
|
||||
a => (
|
||||
((r * 255 + a / 2) / a).min(255),
|
||||
((g * 255 + a / 2) / a).min(255),
|
||||
((b * 255 + a / 2) / a).min(255),
|
||||
),
|
||||
};
|
||||
out.extend_from_slice(&[r as u8, g as u8, b as u8, a as u8]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
|
||||
};
|
||||
|
||||
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
|
||||
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = family.to_be_bytes().to_vec();
|
||||
for f in [address, number, name, data] {
|
||||
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(f);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
|
||||
// The scratch file lives beside the test binary's temp dir; unique per call via the address
|
||||
// of a local (no rand dependency, and the tests do not run concurrently on one path).
|
||||
let mut p = std::env::temp_dir();
|
||||
let uniq = format!(
|
||||
"pf-xauth-test-{}-{:p}",
|
||||
std::process::id(),
|
||||
bytes as *const [u8]
|
||||
);
|
||||
p.push(uniq);
|
||||
std::fs::write(&p, bytes).expect("write scratch xauth");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_number_handles_every_display_spelling() {
|
||||
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
|
||||
// Nothing to match on — the caller then accepts only a wildcard entry.
|
||||
assert_eq!(display_number("bogus"), None);
|
||||
assert_eq!(display_number(":"), None);
|
||||
assert_eq!(display_number(":abc"), None);
|
||||
}
|
||||
|
||||
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
|
||||
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
|
||||
/// truncation.
|
||||
#[test]
|
||||
fn mit_magic_cookie_picks_the_matching_entry() {
|
||||
let mut file = Vec::new();
|
||||
// A different protocol on the display we want — must be skipped, not returned.
|
||||
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
|
||||
// Another display's cookie.
|
||||
file.extend(entry(
|
||||
256,
|
||||
b"host",
|
||||
b"7",
|
||||
MIT_MAGIC_COOKIE_1,
|
||||
b"other-display",
|
||||
));
|
||||
// Ours.
|
||||
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
|
||||
let p = write_xauth(&file);
|
||||
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
|
||||
assert_eq!(
|
||||
got,
|
||||
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
|
||||
);
|
||||
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
|
||||
// An empty `number` matches any display — but an exact match still wins.
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
|
||||
let p = write_xauth(&file);
|
||||
let path = p.to_str().unwrap();
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":3").map(|(_, d)| d),
|
||||
Some(b"exact".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":4").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
// A second entry cut off mid-length-prefix.
|
||||
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
|
||||
file.truncate(file.len() - 4);
|
||||
let p = write_xauth(&file);
|
||||
// No panic, no over-read: the wildcard found before the truncation still serves.
|
||||
assert_eq!(
|
||||
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_garbage_xauthority_is_declined_not_fatal() {
|
||||
// A length prefix that claims far more than the file holds.
|
||||
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
// A missing file is simply "no cookie".
|
||||
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
|
||||
}
|
||||
|
||||
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
|
||||
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
|
||||
#[test]
|
||||
fn pointer_positions_are_mapped_into_frame_space() {
|
||||
// The measured case: root 640x360 inside a 1280x720 output — 2x.
|
||||
assert_eq!(
|
||||
scale_to_frame((320, 180), (640, 360), (1280, 720)),
|
||||
(640, 360)
|
||||
);
|
||||
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
|
||||
// Down-scaling works the same way.
|
||||
assert_eq!(
|
||||
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
|
||||
(640, 360)
|
||||
);
|
||||
// Equal spaces are a pass-through (the common case — no rounding drift).
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
|
||||
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
|
||||
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
|
||||
}
|
||||
|
||||
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
|
||||
#[test]
|
||||
fn scaling_does_not_overflow_at_5k() {
|
||||
assert_eq!(
|
||||
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
|
||||
(5118, 2878)
|
||||
);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,648 +0,0 @@
|
||||
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
|
||||
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
|
||||
//! with.
|
||||
//!
|
||||
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
|
||||
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
|
||||
//! the shader sources, the three converters); this is the validation path.
|
||||
//!
|
||||
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
|
||||
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
/// Used by [`hdr_p010_selftest`].
|
||||
#[cfg(target_os = "windows")]
|
||||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
fn pq_oetf(l: f64) -> f64 {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
let m1 = 0.1593017578125;
|
||||
let m2 = 78.84375;
|
||||
let c1 = 0.8359375;
|
||||
let c2 = 18.8515625;
|
||||
let c3 = 18.6875;
|
||||
let lp = l.powf(m1);
|
||||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||||
}
|
||||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||||
let m = [
|
||||
[0.627403914, 0.329283038, 0.043313048],
|
||||
[0.069097292, 0.919540405, 0.011362303],
|
||||
[0.016391439, 0.088013308, 0.895595253],
|
||||
];
|
||||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||||
// PQ encode (normalize to 10k nits).
|
||||
let pr = pq_oetf(lr / 10000.0);
|
||||
let pg = pq_oetf(lg / 10000.0);
|
||||
let pb = pq_oetf(lb / 10000.0);
|
||||
// BT.2020 non-constant-luminance, limited 10-bit.
|
||||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||||
let y = kr * pr + kg * pg + kb * pb;
|
||||
let cb = (pb - y) / 1.8814;
|
||||
let cr = (pr - y) / 1.4746;
|
||||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||||
(yc, cbc, crc)
|
||||
}
|
||||
|
||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, w, h)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
|
||||
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
|
||||
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
|
||||
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
|
||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||
///
|
||||
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
|
||||
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
|
||||
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
|
||||
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
|
||||
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
|
||||
/// because a PASS only means anything for the GPU it actually ran on.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
("red1.0", 1.0, 0.0, 0.0),
|
||||
("green0.5", 0.0, 0.5, 0.0),
|
||||
("blue4.0", 0.0, 0.0, 4.0),
|
||||
("white1.0", 1.0, 1.0, 1.0),
|
||||
("black", 0.0, 0.0, 0.0),
|
||||
("gray0.5", 0.5, 0.5, 0.5),
|
||||
("white4.0", 4.0, 4.0, 4.0),
|
||||
("amber2.0", 2.0, 1.0, 0.0),
|
||||
];
|
||||
|
||||
let grid_cols = W / BLK; // 4
|
||||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||||
if idx < named.len() {
|
||||
let (_, r, g, b) = named[idx];
|
||||
(r, g, b, true)
|
||||
} else {
|
||||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||||
let r = (x as f32 / W as f32) * 3.0;
|
||||
let g = (y as f32 / H as f32) * 3.0;
|
||||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||||
(r, g, b, false)
|
||||
}
|
||||
};
|
||||
|
||||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||||
let mut flat = vec![false; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
flat[(y * W + x) as usize] = is_flat;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 src)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 src)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// P010 destination texture (render-target bindable).
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, W, H)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
|
||||
|
||||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||||
.context("CreateTexture2D(P010 staging)")?;
|
||||
let staging = staging.context("null staging")?;
|
||||
context.CopyResource(&staging, &p010);
|
||||
|
||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||||
.context("Map(P010 staging)")?;
|
||||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||||
let base = map.pData as *const u8;
|
||||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||||
tracing::info!(
|
||||
row_pitch,
|
||||
depth_pitch = map.DepthPitch,
|
||||
expected_chroma_base = row_pitch * H as usize,
|
||||
expected_total = row_pitch * H as usize * 3 / 2,
|
||||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||||
);
|
||||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||||
let read_u16 = |byte_off: usize| -> u16 {
|
||||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||||
let p = base.add(byte_off) as *const u16;
|
||||
p.read_unaligned()
|
||||
};
|
||||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||||
let mut y_codes = vec![0u16; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||||
}
|
||||
}
|
||||
let cw = W / 2;
|
||||
let ch = H / 2;
|
||||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||||
}
|
||||
}
|
||||
context.Unmap(&staging, 0);
|
||||
|
||||
// Compare Y over every pixel.
|
||||
let mut max_y_err = 0.0f64;
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, _) = pixel_rgb(x, y);
|
||||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let got = y_codes[(y * W + x) as usize] as f64;
|
||||
max_y_err = max_y_err.max((got - ry).abs());
|
||||
}
|
||||
}
|
||||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||||
let mut max_u_err = 0.0f64;
|
||||
let mut max_v_err = 0.0f64;
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let (sx, sy) = (cx * 2, cy * 2);
|
||||
let all_flat =
|
||||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||||
if !all_flat {
|
||||
continue;
|
||||
}
|
||||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||||
max_u_err = max_u_err.max((gu - rcb).abs());
|
||||
max_v_err = max_v_err.max((gv - rcr).abs());
|
||||
}
|
||||
}
|
||||
|
||||
// Per-colour table.
|
||||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||||
println!(
|
||||
" {:<10} {:>14} {:>14} {:>14}",
|
||||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||||
);
|
||||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||||
let (ccx, ccy) = (bx / 2, by / 2);
|
||||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
println!(
|
||||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||||
name, ey, gy, ecb, gu, ecr, gv
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||||
);
|
||||
|
||||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||||
println!("PASS");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("FAIL");
|
||||
bail!(
|
||||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn f32_to_f16(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let mant = bits & 0x007f_ffff;
|
||||
if exp <= 0 {
|
||||
// Subnormal / zero in half precision.
|
||||
if exp < -10 {
|
||||
return sign; // too small → ±0
|
||||
}
|
||||
let mant = mant | 0x0080_0000; // implicit 1
|
||||
let shift = (14 - exp) as u32;
|
||||
let half_mant = (mant >> shift) as u16;
|
||||
// Round to nearest.
|
||||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||||
sign | (half_mant + round)
|
||||
} else if exp >= 0x1f {
|
||||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||||
} else {
|
||||
let half_exp = (exp as u16) << 10;
|
||||
let half_mant = (mant >> 13) as u16;
|
||||
let round = ((mant >> 12) & 1) as u16;
|
||||
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
|
||||
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
|
||||
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
|
||||
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
|
||||
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
|
||||
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
|
||||
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
|
||||
// correct shader. The subnormal branch above was already additive.
|
||||
sign | (half_exp + half_mant + round)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod f16_tests {
|
||||
use super::f32_to_f16;
|
||||
|
||||
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
|
||||
fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = ((h >> 10) & 0x1f) as i32;
|
||||
let mant = (h & 0x3ff) as f32;
|
||||
match exp {
|
||||
0 => sign * mant * 2f32.powi(-24), // subnormal
|
||||
31 => sign * f32::INFINITY, // our encoder never emits NaN
|
||||
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
|
||||
}
|
||||
}
|
||||
|
||||
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
|
||||
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
|
||||
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
|
||||
/// of, which made `hdr-p010-selftest` fail a correct shader.
|
||||
#[test]
|
||||
fn a_rounding_carry_increments_the_exponent() {
|
||||
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
|
||||
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
|
||||
// The two measured regressions, by value.
|
||||
assert_eq!(
|
||||
f32_to_f16(1.9998779),
|
||||
0x4000,
|
||||
"1.9998779 must not read as 1.0"
|
||||
);
|
||||
assert_eq!(
|
||||
f32_to_f16(0.49996948),
|
||||
0x3800,
|
||||
"0.49996948 must not read as 0.25"
|
||||
);
|
||||
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
|
||||
// the fix must not change it.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_constants_the_selftest_uploads_are_exact() {
|
||||
assert_eq!(f32_to_f16(0.0), 0x0000);
|
||||
assert_eq!(f32_to_f16(-0.0), 0x8000);
|
||||
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16(-1.0), 0xBC00);
|
||||
assert_eq!(f32_to_f16(0.5), 0x3800);
|
||||
assert_eq!(f32_to_f16(2.0), 0x4000);
|
||||
assert_eq!(f32_to_f16(4.0), 0x4400);
|
||||
}
|
||||
|
||||
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
|
||||
/// f16 ULP — the property the P010 comparison actually depends on.
|
||||
#[test]
|
||||
fn hdr_scrgb_values_round_trip_within_one_ulp() {
|
||||
for &v in &[
|
||||
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
|
||||
3.999, 0.001,
|
||||
] {
|
||||
let back = f16_to_f32(f32_to_f16(v));
|
||||
// One ULP at this magnitude: f16 carries 11 significand bits.
|
||||
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
|
||||
assert!(
|
||||
(back - v).abs() <= ulp,
|
||||
"{v} round-tripped to {back} (ulp {ulp})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
|
||||
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
|
||||
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
|
||||
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
|
||||
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
|
||||
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,22 +105,6 @@ impl ChannelBroker {
|
||||
Ok(out.0 as usize as u64)
|
||||
}
|
||||
|
||||
/// Duplicate the cursor section into WUDFHost (v5 cursor channel) with the same
|
||||
/// least-privilege section rights as the frame header. Thin `pub(super)` face over
|
||||
/// [`dup_into`](Self::dup_into) for the cursor-delivery path in `open_on`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `h` must be a live handle of the current process.
|
||||
pub(super) unsafe fn dup_into_public(&self, h: HANDLE) -> Result<u64> {
|
||||
// SAFETY: forwarded contract — `h` is live per this fn's own contract.
|
||||
unsafe { self.dup_into(h, Some(SECTION_MAP_RW)) }
|
||||
}
|
||||
|
||||
/// [`close_remote`](Self::close_remote) for the cursor-delivery failure path.
|
||||
pub(super) fn close_remote_public(&self, value: u64) {
|
||||
self.close_remote(value);
|
||||
}
|
||||
|
||||
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
|
||||
/// with no target closes the source handle regardless of the (ignored) result.
|
||||
fn close_remote(&self, value: u64) {
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
|
||||
//! display so DWM presents it.
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
|
||||
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
|
||||
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
|
||||
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
|
||||
//! `FrameStash` republish.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
|
||||
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
|
||||
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
|
||||
/// though everything is healthy.
|
||||
///
|
||||
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
|
||||
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
|
||||
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
|
||||
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
|
||||
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
|
||||
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
|
||||
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
|
||||
///
|
||||
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
||||
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
|
||||
/// `punktfunk-probe --input-test` always relied on).
|
||||
///
|
||||
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
|
||||
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
|
||||
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
|
||||
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
|
||||
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
|
||||
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
|
||||
/// the cursor layer of the display it lands on, so the target composes at least one frame).
|
||||
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
|
||||
/// happened anyway.
|
||||
///
|
||||
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
|
||||
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
|
||||
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
|
||||
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
|
||||
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
|
||||
/// plus the callers' own 600–800 ms schedules bound how often it can happen.
|
||||
///
|
||||
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
|
||||
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
|
||||
/// HID device is real input to win32k — delivered regardless of this process's session or the
|
||||
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
|
||||
/// modern standby) and counts as user presence — every condition under which `SendInput` is
|
||||
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
|
||||
/// nothing composes at all). That set is exactly the lid-closed field-report state.
|
||||
pub(super) fn kick_dwm_compose(target_id: u32) {
|
||||
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
||||
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
||||
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
|
||||
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
|
||||
// 600–800 ms per-capturer schedules.
|
||||
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
{
|
||||
let mut last = LAST_KICK.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
|
||||
return;
|
||||
}
|
||||
*last = Some(now);
|
||||
}
|
||||
// Where is the cursor, and where does the target display live in desktop space?
|
||||
let mut pos = POINT::default();
|
||||
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
|
||||
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers; the `Copy` target id crosses by value.
|
||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
|
||||
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
|
||||
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
|
||||
// through to SendInput only when the hook isn't registered / the mouse isn't up.
|
||||
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
|
||||
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers.
|
||||
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
|
||||
if let Some(bounds) = bounds {
|
||||
if kick(rect, bounds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
||||
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
||||
if !inside {
|
||||
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
|
||||
// to the target's center, DWELL one composition interval, then restore. The dwell is
|
||||
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
|
||||
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
|
||||
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
|
||||
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
|
||||
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
|
||||
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
|
||||
// observed original position.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(x + w / 2, y + h / 2);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(pos.x, pos.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mk = |dx: i32| INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx,
|
||||
dy: 0,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
|
||||
// call, and `cbsize` is the true element size.
|
||||
unsafe {
|
||||
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//! Host side of the v5 hardware-cursor channel (remote-desktop-sweep M2c): the capturer creates
|
||||
//! an unnamed [`CursorShm`] section, delivers it to the pf-vdisplay driver (which declares an
|
||||
//! IddCx hardware cursor — DWM then EXCLUDES the pointer from the frames we consume), and reads
|
||||
//! the driver's seqlock publishes here at encode-tick pace, converting them into the same
|
||||
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
|
||||
//! cursor forwarder, the wire, the client renderer) is shared.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use pf_driver_proto::cursor::{
|
||||
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
|
||||
CURSOR_SHM_SIZE, CURSOR_TYPE_MASKED_COLOR,
|
||||
};
|
||||
use std::sync::atomic::AtomicU32;
|
||||
|
||||
/// The host end of one monitor's cursor channel: the section (we created it — the mapping stays
|
||||
/// valid for the capturer's life) plus the reader's conversion cache.
|
||||
pub(super) struct CursorShared {
|
||||
section: MappedSection,
|
||||
/// The monitor's desktop origin — IddCx reports positions in DESKTOP coordinates; the
|
||||
/// overlay wants frame-relative. Fetched at attach (the virtual monitor's placement is
|
||||
/// stable for the session; a topology change recreates the pipeline anyway).
|
||||
origin: (i32, i32),
|
||||
/// Conversion cache: the last `shape_id` whose pixels were converted, and the result.
|
||||
/// Position-only updates (the common case) reuse it — a refcount bump, no pixel work.
|
||||
cached_id: u32,
|
||||
cached: Option<ConvertedShape>,
|
||||
}
|
||||
|
||||
struct ConvertedShape {
|
||||
rgba: std::sync::Arc<Vec<u8>>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
hot_x: u32,
|
||||
hot_y: u32,
|
||||
}
|
||||
|
||||
impl CursorShared {
|
||||
/// Create + initialize the section (magic stamped, seq even/zero). The returned handle is
|
||||
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
|
||||
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
|
||||
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
|
||||
// mapped once and unmapped never (the capturer's life = the session's life).
|
||||
let section = unsafe {
|
||||
let map = CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
None,
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
CURSOR_SHM_SIZE as u32,
|
||||
PCWSTR::null(),
|
||||
)
|
||||
.context("CreateFileMapping(cursor)")?;
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
CURSOR_SHM_SIZE,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
bail!("MapViewOfFile failed for the cursor section");
|
||||
}
|
||||
let shm = view.Value.cast::<CursorShm>();
|
||||
std::ptr::write_bytes(view.Value.cast::<u8>(), 0, CURSOR_SHM_SIZE);
|
||||
// Magic LAST-ish (the driver validates it at adopt; seq 0 = even = consistent).
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
(*shm).magic = CURSOR_MAGIC;
|
||||
MappedSection { handle: map, view }
|
||||
};
|
||||
// Desktop origin of this monitor's source — for the desktop→frame coordinate shift.
|
||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned
|
||||
// locals (same call the compose-kick path makes).
|
||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
let origin = rect.map(|(x, y, _w, _h)| (x, y)).unwrap_or((0, 0));
|
||||
Ok(CursorShared {
|
||||
section,
|
||||
origin,
|
||||
cached_id: 0,
|
||||
cached: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The section handle for the broker's duplication into the WUDFHost.
|
||||
pub(super) fn section_handle(&self) -> HANDLE {
|
||||
HANDLE(self.section.handle.as_raw_handle())
|
||||
}
|
||||
|
||||
/// Seqlock-read the driver's latest publish → a frame-relative [`pf_frame::CursorOverlay`].
|
||||
/// `None` until the first publish lands (or while the pointer has never been seen). A hidden
|
||||
/// pointer returns `Some` with `visible: false` — the forwarder turns that into the client's
|
||||
/// relative-mode hint, exactly like the Linux path.
|
||||
pub(super) fn read(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
let shm = self.section.ptr::<CursorShm>();
|
||||
// SAFETY: the view spans CURSOR_SHM_SIZE for self's lifetime; seq is 4-aligned in the
|
||||
// fixed layout (offset 4).
|
||||
let seq = unsafe { &*std::ptr::addr_of!((*shm).seq).cast::<AtomicU32>() };
|
||||
for _ in 0..64 {
|
||||
let s1 = seq.load(Ordering::Acquire);
|
||||
if s1 == 0 {
|
||||
return None; // no publish yet
|
||||
}
|
||||
if s1 & 1 != 0 {
|
||||
std::hint::spin_loop();
|
||||
continue; // writer mid-update
|
||||
}
|
||||
// SAFETY: header reads within the mapped view; consistency is validated by the
|
||||
// seq re-check below (a torn read is discarded and retried).
|
||||
let hdr = unsafe { std::ptr::read_volatile(shm) };
|
||||
// Shape pixels: convert only when the OS minted a new shape id.
|
||||
if hdr.visible != 0 && hdr.shape_id != self.cached_id {
|
||||
let rows = hdr.height.min(CURSOR_SHAPE_MAX) as usize;
|
||||
let width = hdr.width.min(CURSOR_SHAPE_MAX) as usize;
|
||||
let pitch = (hdr.pitch as usize).min(CURSOR_SHAPE_BYTES / rows.max(1));
|
||||
let mut raw = vec![0u8; rows * pitch];
|
||||
// SAFETY: the shape region spans CURSOR_SHAPE_BYTES from CURSOR_SHAPE_OFFSET
|
||||
// inside the mapped view; `rows * pitch` is clamped to it above.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
self.section.ptr::<u8>().add(CURSOR_SHAPE_OFFSET),
|
||||
raw.as_mut_ptr(),
|
||||
rows * pitch,
|
||||
);
|
||||
}
|
||||
// Discard the copy if the writer raced us mid-shape (seq moved) — retry.
|
||||
if seq.load(Ordering::Acquire) != s1 {
|
||||
continue;
|
||||
}
|
||||
self.cached = Some(convert_shape(&hdr, &raw, width, rows, pitch));
|
||||
self.cached_id = hdr.shape_id;
|
||||
} else if seq.load(Ordering::Acquire) != s1 {
|
||||
continue;
|
||||
}
|
||||
let shape = self.cached.as_ref()?;
|
||||
return Some(pf_frame::CursorOverlay {
|
||||
x: hdr.x - self.origin.0,
|
||||
y: hdr.y - self.origin.1,
|
||||
w: shape.w,
|
||||
h: shape.h,
|
||||
rgba: shape.rgba.clone(),
|
||||
serial: u64::from(hdr.shape_id),
|
||||
hot_x: shape.hot_x,
|
||||
hot_y: shape.hot_y,
|
||||
visible: hdr.visible != 0,
|
||||
});
|
||||
}
|
||||
None // persistent tearing (writer wedged mid-seq) — skip this tick
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the OS's 32-bpp pitch-strided shape rows into the overlay's packed straight RGBA.
|
||||
/// ALPHA cursors are BGRA with straight per-pixel alpha (swap R↔B). MASKED_COLOR approximates:
|
||||
/// alpha 0x00 = opaque color pixel; 0xFF = an XOR pixel we cannot honor client-side — rendered
|
||||
/// as a translucent mid-gray so inversion cursors stay visible instead of vanishing.
|
||||
fn convert_shape(
|
||||
hdr: &CursorShm,
|
||||
raw: &[u8],
|
||||
width: usize,
|
||||
rows: usize,
|
||||
pitch: usize,
|
||||
) -> ConvertedShape {
|
||||
let masked = hdr.cursor_type == CURSOR_TYPE_MASKED_COLOR;
|
||||
let mut rgba = Vec::with_capacity(width * rows * 4);
|
||||
for y in 0..rows {
|
||||
let row = &raw[y * pitch..];
|
||||
for x in 0..width {
|
||||
let o = x * 4;
|
||||
if o + 4 > row.len() {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
continue;
|
||||
}
|
||||
let (b, g, r, a) = (row[o], row[o + 1], row[o + 2], row[o + 3]);
|
||||
if masked {
|
||||
if a == 0 {
|
||||
rgba.extend_from_slice(&[r, g, b, 0xFF]);
|
||||
} else {
|
||||
rgba.extend_from_slice(&[0x80, 0x80, 0x80, 0xB4]);
|
||||
}
|
||||
} else {
|
||||
rgba.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ConvertedShape {
|
||||
rgba: std::sync::Arc::new(rgba),
|
||||
w: width as u32,
|
||||
h: rows as u32,
|
||||
hot_x: hdr.hot_x.min(width.saturating_sub(1) as u32),
|
||||
hot_y: hdr.hot_y.min(rows.saturating_sub(1) as u32),
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
//! Host-side cursor compositing for the CAPTURE mouse model (design/remote-desktop-sweep.md §8).
|
||||
//!
|
||||
//! Why the host draws it: once a monitor has ever declared an IddCx hardware cursor, DWM will
|
||||
//! not composite the software cursor back into its frames — there is no un-declare DDI (the
|
||||
//! empty-caps re-setup is rejected `STATUS_INVALID_PARAMETER`), and a successful same-mode
|
||||
//! re-commit with the driver's re-declare provably suppressed still leaves the pointer excluded
|
||||
//! (all observed on-glass, 26100). So the driver keeps its hardware cursor declared for the
|
||||
//! session's whole life — the state that works — and when the client flips to the capture model
|
||||
//! the HOST composites the pointer into the frame itself: a slot→scratch copy plus one
|
||||
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
|
||||
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::core::s;
|
||||
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11BlendState, ID3D11Buffer, ID3D11PixelShader, ID3D11SamplerState, ID3D11VertexShader,
|
||||
D3D11_BIND_CONSTANT_BUFFER, D3D11_BLEND_DESC, D3D11_BLEND_INV_SRC_ALPHA, D3D11_BLEND_ONE,
|
||||
D3D11_BLEND_OP_ADD, D3D11_BLEND_SRC_ALPHA, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER,
|
||||
D3D11_CPU_ACCESS_WRITE, D3D11_FILTER_MIN_MAG_MIP_LINEAR, D3D11_MAPPED_SUBRESOURCE,
|
||||
D3D11_MAP_WRITE_DISCARD, D3D11_RENDER_TARGET_BLEND_DESC, D3D11_SAMPLER_DESC,
|
||||
D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DYNAMIC, D3D11_VIEWPORT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
|
||||
/// Straight-alpha sample of the cursor bitmap. `linear_scale` = 0 passes sRGB through (SDR
|
||||
/// ring); non-zero linearizes sRGB→scRGB AND multiplies by the target's SDR-white scale
|
||||
/// (`sdr_white_level_scale` — 1.0 would put cursor-white at 80 nits, visibly DARKER than the
|
||||
/// surrounding SDR desktop content DWM composes at the user's SDR-brightness setting).
|
||||
const CURSOR_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
SamplerState sm : register(s0);
|
||||
cbuffer C : register(b0) { float linear_scale; float3 pad; };
|
||||
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target {
|
||||
float4 c = tx.Sample(sm, uv);
|
||||
if (linear_scale != 0.0) {
|
||||
c.rgb = pow(abs(c.rgb), 2.2) * linear_scale;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
";
|
||||
|
||||
/// The cursor-quad blend pass + its shape-texture cache. One per capturer (device-scoped).
|
||||
pub(super) struct CursorBlendPass {
|
||||
vs: ID3D11VertexShader,
|
||||
ps: ID3D11PixelShader,
|
||||
sampler: ID3D11SamplerState,
|
||||
blend: ID3D11BlendState,
|
||||
cbuf: ID3D11Buffer,
|
||||
cbuf_scale: Option<f32>,
|
||||
/// The uploaded shape (serial-keyed): SRV + dims in host pixels.
|
||||
shape: Option<(u64, ID3D11ShaderResourceView, u32, u32)>,
|
||||
}
|
||||
|
||||
impl CursorBlendPass {
|
||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||
// literals (its contract).
|
||||
unsafe {
|
||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps = None;
|
||||
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
|
||||
let sd = D3D11_SAMPLER_DESC {
|
||||
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
|
||||
// half-texel edges; linear keeps them soft instead of ringing.
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
ComparisonFunc: D3D11_COMPARISON_NEVER,
|
||||
MaxLOD: f32::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
|
||||
let mut bd = D3D11_BLEND_DESC::default();
|
||||
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
|
||||
BlendEnable: true.into(),
|
||||
SrcBlend: D3D11_BLEND_SRC_ALPHA,
|
||||
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
|
||||
BlendOp: D3D11_BLEND_OP_ADD,
|
||||
SrcBlendAlpha: D3D11_BLEND_ONE,
|
||||
DestBlendAlpha: D3D11_BLEND_ONE,
|
||||
BlendOpAlpha: D3D11_BLEND_OP_ADD,
|
||||
RenderTargetWriteMask: 0x0F,
|
||||
};
|
||||
let mut blend = None;
|
||||
device.CreateBlendState(&bd, Some(&mut blend))?;
|
||||
let cbd = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 16, // float to_linear + float3 pad
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cbuf = None;
|
||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("cursor blend vs")?,
|
||||
ps: ps.context("cursor blend ps")?,
|
||||
sampler: sampler.context("cursor blend sampler")?,
|
||||
blend: blend.context("cursor blend state")?,
|
||||
cbuf: cbuf.context("cursor blend cbuf")?,
|
||||
cbuf_scale: None,
|
||||
shape: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||
unsafe fn ensure_shape(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
) -> Result<()> {
|
||||
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
||||
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
|
||||
// outlives the synchronous upload.
|
||||
unsafe {
|
||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||
return Ok(());
|
||||
}
|
||||
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
|
||||
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: ov.w,
|
||||
Height: ov.h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: ov.rgba.as_ptr().cast(),
|
||||
SysMemPitch: ov.w * 4,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
||||
.context("CreateTexture2D(cursor shape)")?;
|
||||
let tex = tex.context("null cursor shape texture")?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(cursor shape)")?;
|
||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
||||
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
|
||||
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
|
||||
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
|
||||
/// the target automatically.
|
||||
pub(super) unsafe fn blend(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
dst: &ID3D11Texture2D,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
linear_scale: f32,
|
||||
) -> Result<()> {
|
||||
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
|
||||
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
|
||||
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
|
||||
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
|
||||
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
|
||||
// interfaces.
|
||||
unsafe {
|
||||
self.ensure_shape(device, ov)?;
|
||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
||||
if self.cbuf_scale != Some(linear_scale) {
|
||||
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
if ctx
|
||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.is_ok()
|
||||
{
|
||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||
ctx.Unmap(&self.cbuf, 0);
|
||||
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
|
||||
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
|
||||
// buffer still held the OLD value while this believed it held the new one, and
|
||||
// no later call would retry. On failure the cache is left alone and the next
|
||||
// blend tries again — a stale scale for a frame instead of forever.
|
||||
self.cbuf_scale = Some(linear_scale);
|
||||
}
|
||||
}
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device
|
||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(cursor blend scratch)")?;
|
||||
let rtv = rtv.context("null cursor blend rtv")?;
|
||||
|
||||
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
|
||||
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShader(&self.ps, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
|
||||
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: ov.x as f32,
|
||||
TopLeftY: ov.y as f32,
|
||||
Width: *w as f32,
|
||||
Height: *h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
ctx.RSSetViewports(Some(&[vp]));
|
||||
ctx.Draw(3, 0);
|
||||
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
|
||||
ctx.OMSetRenderTargets(None, None);
|
||||
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
|
||||
ctx.PSSetShaderResources(0, Some(&none_srv));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
//! GDI cursor poller — the Windows cursor-SHAPE source for the cursor-forward channel
|
||||
//! (design/remote-desktop-sweep.md §8, the M2c redesign).
|
||||
//!
|
||||
//! Why not the IddCx hardware-cursor query (the v5 `CursorShm` path, now the fallback): it is
|
||||
//! alpha-only BY DESIGN — `IDDCX_CURSOR_SHAPE_TYPE` has no monochrome value, the OS pre-converts
|
||||
//! monochrome to masked-color (IDDCX_CURSOR_CAPS docs), and masked-color delivery is dead code on
|
||||
//! modern builds (proven on-glass at every `ColorXorCursorSupport` level; no public evidence of a
|
||||
//! MASKED_COLOR delivery anywhere). The driver keeps its hardware cursor declared at XOR FULL
|
||||
//! purely so DWM EXCLUDES every cursor type from the IDD frame.
|
||||
//!
|
||||
//! Why not DXGI Desktop Duplication `GetFramePointerShape`: its `PointerPosition.Visible` goes
|
||||
//! stale when the cursor moves only via injected input on current Win11 (Sunshine #5293 — exactly
|
||||
//! a Punktfunk session's topology), it burns one of the session's four duplication slots, and its
|
||||
//! per-output metadata on IDD monitors has conflicting field reports. The GDI path below is the
|
||||
//! metadata-forwarding-remote-desktop pattern (RustDesk, WebRTC/Chrome Remote Desktop, OBS): the
|
||||
//! cursor is per-session global state in win32k, readable cross-process, and `CURSOR_SHOWING` is
|
||||
//! the logical visibility — immune to all of the above.
|
||||
//!
|
||||
//! Works because the capture host runs as SYSTEM *inside the interactive session* on
|
||||
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
|
||||
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
|
||||
BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC,
|
||||
};
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
||||
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
|
||||
};
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CopyIcon, DestroyIcon, GetCursorInfo, GetIconInfo, CURSORINFO, HICON, ICONINFO,
|
||||
};
|
||||
|
||||
/// `CURSORINFO.flags` bits (WindowsAndMessaging): the pointer is logically shown /
|
||||
/// touch-or-pen-suppressed. Named locally so the visibility rule below reads as the docs do.
|
||||
const CURSOR_SHOWING: u32 = 0x1;
|
||||
const CURSOR_SUPPRESSED: u32 = 0x2;
|
||||
|
||||
/// A converted shape: the cache the per-tick overlay is assembled from. `rgba` is `Arc` so the
|
||||
/// slot publish (and every downstream frame attach) is a refcount bump.
|
||||
struct Shape {
|
||||
rgba: std::sync::Arc<Vec<u8>>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
hot_x: u32,
|
||||
hot_y: u32,
|
||||
serial: u64,
|
||||
}
|
||||
|
||||
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
|
||||
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
|
||||
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
|
||||
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
|
||||
/// on the capture/encode thread).
|
||||
pub(super) struct CursorPoller {
|
||||
slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// The input desktop is a SECURE desktop (Winlogon — UAC consent / lock / logon). Classified
|
||||
/// on every reattach; the capturer polls it to stand the IddCx hardware-cursor declare down
|
||||
/// while the secure desktop needs the software-cursor path to render (see
|
||||
/// `IddPushCapturer::poll_secure_desktop`).
|
||||
secure: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl CursorPoller {
|
||||
/// ~250 Hz: the polled position is ALSO the composite-blend position (capture model), so
|
||||
/// it must out-pace the fastest session — at 16 ms a 240 fps stream re-used a stale
|
||||
/// position for ~4 consecutive frames and the composited pointer visibly stuttered
|
||||
/// against the video. A tick is one `GetCursorInfo` syscall (rasterisation only on shape
|
||||
/// change), so 250 Hz is still negligible CPU.
|
||||
const INTERVAL: Duration = Duration::from_millis(4);
|
||||
/// Unconditional input-desktop reattach cadence — catches secure-desktop (UAC/lock) switches
|
||||
/// without a failure signal (`GetCursorInfo` on a stale desktop *succeeds* with stale data).
|
||||
/// 250 ms, not the original 2 s: the reattach now also feeds [`Self::secure_desktop`], which
|
||||
/// gates when the secure desktop becomes VISIBLE in the stream (the hardware-cursor
|
||||
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
||||
/// syscalls/s are not.
|
||||
const REATTACH: Duration = Duration::from_millis(250);
|
||||
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
|
||||
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
|
||||
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
|
||||
const EXTENT_PROBE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
|
||||
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
||||
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
||||
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
||||
///
|
||||
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
|
||||
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
|
||||
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
|
||||
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
|
||||
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
|
||||
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
|
||||
/// this poller exists (see `DescriptorPoller`).
|
||||
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let secure = Arc::new(AtomicBool::new(false));
|
||||
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-cursor-poll".into())
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
|
||||
.ok();
|
||||
if thread.is_none() {
|
||||
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
|
||||
}
|
||||
Self {
|
||||
slot,
|
||||
stop,
|
||||
secure,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest overlay snapshot (`None` until the first successful shape rasterisation).
|
||||
pub(super) fn read(&self) -> Option<pf_frame::CursorOverlay> {
|
||||
self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Whether the input desktop is currently a SECURE desktop (UAC consent / Winlogon lock or
|
||||
/// logon). Latched by the poll thread on its reattach cadence (≤ [`Self::REATTACH`] stale).
|
||||
pub(super) fn secure_desktop(&self) -> bool {
|
||||
self.secure.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
|
||||
pub(super) fn alive(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| !t.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CursorPoller {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join(); // worker sleeps ≤ INTERVAL — a bounded join
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
||||
fn run(
|
||||
target_id: u32,
|
||||
mut rect: (i32, i32, i32, i32),
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
) {
|
||||
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
|
||||
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
|
||||
// would land in the wrong frame pixel on any scaled display. Thread-scoped, so the rest of
|
||||
// the host is untouched.
|
||||
// SAFETY: takes and returns only a by-value context handle; affects this thread only.
|
||||
let _ = unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
|
||||
|
||||
let mut desktop = DesktopBinding::default();
|
||||
// best-effort: already on winsta0\default if this fails
|
||||
publish_secure(secure, desktop.reattach());
|
||||
let mut last_attach = Instant::now();
|
||||
|
||||
let mut shape: Option<Shape> = None;
|
||||
let mut cached_handle: isize = 0;
|
||||
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
||||
let mut serial: u64 = 0;
|
||||
let mut logged_live = false;
|
||||
let mut last_extent = Instant::now();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(CursorPoller::INTERVAL);
|
||||
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
||||
last_attach = Instant::now();
|
||||
publish_secure(secure, desktop.reattach());
|
||||
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
|
||||
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
|
||||
// BOTH the origin the position is made relative to and the extent `in_rect` tests
|
||||
// against, and this poller outlives all of them. `None` keeps the last good value — a
|
||||
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
|
||||
// report every position invisible.
|
||||
//
|
||||
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
|
||||
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
|
||||
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
|
||||
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
if let Some(fresh) = fresh {
|
||||
if fresh != rect {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
from = ?rect,
|
||||
to = ?fresh,
|
||||
"cursor poller: target desktop rect changed — re-basing pointer positions"
|
||||
);
|
||||
rect = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ci = CURSORINFO {
|
||||
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `ci` is a live, correctly-sized out-param for this synchronous call; no pointer
|
||||
// escapes it.
|
||||
if unsafe { GetCursorInfo(&mut ci) }.is_err() {
|
||||
// Desktop went away under us (secure-desktop switch mid-call) — rebind and retry
|
||||
// next tick; the slot keeps its last snapshot meanwhile.
|
||||
publish_secure(secure, desktop.reattach());
|
||||
last_attach = Instant::now();
|
||||
continue;
|
||||
}
|
||||
|
||||
let flags = ci.flags.0;
|
||||
let showing = flags & CURSOR_SHOWING != 0 && flags & CURSOR_SUPPRESSED == 0;
|
||||
|
||||
// Rasterise on handle change only (position-only ticks are a header update). Hidden
|
||||
// cursors keep the cached shape — the forwarder's hidden-but-known contract needs the
|
||||
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
||||
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
||||
let handle = ci.hCursor.0 as isize;
|
||||
|
||||
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
|
||||
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
|
||||
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
|
||||
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
|
||||
// override a beat later) — while the SHARED handle stays put for the session's life (the
|
||||
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
|
||||
// the pointer happened to have when the poller started and never let go: a session that
|
||||
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
|
||||
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
|
||||
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
|
||||
// (dimensions only, no pixel copy) and drop the cache when it moved.
|
||||
if showing && handle != 0 && handle == cached_handle {
|
||||
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
|
||||
last_extent = Instant::now();
|
||||
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
|
||||
if now != (s.w, s.h) {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"cursor: the pointer bitmap resized under a stable handle \
|
||||
({}x{} -> {}x{}) — re-rasterising (the scale under the pointer moved)",
|
||||
s.w,
|
||||
s.h,
|
||||
now.0,
|
||||
now.1
|
||||
);
|
||||
cached_handle = 0; // re-rasterise below, on this same tick
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
|
||||
// the very next tick against a shape that is current by construction.
|
||||
last_extent = Instant::now();
|
||||
}
|
||||
|
||||
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
||||
match rasterize(ci.hCursor) {
|
||||
Some((rgba, w, h, hot_x, hot_y)) => {
|
||||
serial += 1;
|
||||
shape = Some(Shape {
|
||||
rgba: std::sync::Arc::new(rgba),
|
||||
w,
|
||||
h,
|
||||
hot_x,
|
||||
hot_y,
|
||||
serial,
|
||||
});
|
||||
cached_handle = handle;
|
||||
failed_handle = 0;
|
||||
if !logged_live {
|
||||
logged_live = true;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"cursor poller live — GDI shape source publishing (serial 1: {w}x{h})"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// The owning app may have destroyed the cursor mid-read; keep the previous
|
||||
// shape and don't hammer this handle again until it changes.
|
||||
failed_handle = handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let overlay = shape.as_ref().map(|s| {
|
||||
let (px, py) = (ci.ptScreenPos.x - rect.0, ci.ptScreenPos.y - rect.1);
|
||||
let in_rect = px >= 0 && py >= 0 && px < rect.2 && py < rect.3;
|
||||
pf_frame::CursorOverlay {
|
||||
// Overlay x/y = bitmap top-left (reported position − hotspot), frame pixels.
|
||||
x: px - s.hot_x as i32,
|
||||
y: py - s.hot_y as i32,
|
||||
w: s.w,
|
||||
h: s.h,
|
||||
rgba: s.rgba.clone(),
|
||||
serial: s.serial,
|
||||
hot_x: s.hot_x,
|
||||
hot_y: s.hot_y,
|
||||
visible: showing && in_rect,
|
||||
}
|
||||
});
|
||||
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a reattach's secure-desktop verdict (`None` = classification unavailable — keep the
|
||||
/// previous state rather than flapping the capturer's hardware-cursor stand-down).
|
||||
fn publish_secure(secure: &AtomicBool, verdict: Option<bool>) {
|
||||
if let Some(s) = verdict {
|
||||
secure.store(s, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread's owned input-desktop handle — the [`SendInputInjector`] reattach model
|
||||
/// (`pf-inject` sendinput.rs): keep the current binding, swap on demand, close exactly once.
|
||||
#[derive(Default)]
|
||||
struct DesktopBinding(Option<HDESK>);
|
||||
|
||||
impl DesktopBinding {
|
||||
/// Rebind to the CURRENT input desktop. Returns whether that desktop is a SECURE one
|
||||
/// (`UOI_NAME` != "Default": "Winlogon" during UAC consent / lock / logon) — `None` when the
|
||||
/// input desktop could not be opened, in which case the binding (and the caller's secure
|
||||
/// state) stays put.
|
||||
fn reattach(&mut self) -> Option<bool> {
|
||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` take only by-value args.
|
||||
// `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is either installed (and the
|
||||
// previously-owned handle closed exactly once) or closed on failure — no handle is leaked
|
||||
// or used after close. `SetThreadDesktop` rebinds only this calling thread (which owns
|
||||
// no windows/hooks, so the rebind cannot fail on that account).
|
||||
unsafe {
|
||||
match OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||
) {
|
||||
Ok(h) => {
|
||||
let secure = desktop_is_secure(h);
|
||||
if SetThreadDesktop(h).is_ok() {
|
||||
if let Some(old) = self.0.replace(h) {
|
||||
let _ = CloseDesktop(old);
|
||||
}
|
||||
} else {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
Some(secure)
|
||||
}
|
||||
Err(_) => None, // not privileged for this desktop; stay put
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `UOI_NAME` of `h` != "Default" — i.e. the input desktop is Winlogon (UAC consent / lock /
|
||||
/// logon) or a screen-saver desktop, both of which need the OS's software-cursor render path.
|
||||
/// Unnameable desktops read as NOT secure: the only in-contract failure is a too-small buffer,
|
||||
/// and misreading secure-as-normal merely keeps today's behavior for a beat.
|
||||
fn desktop_is_secure(h: HDESK) -> bool {
|
||||
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
|
||||
let mut needed = 0u32;
|
||||
// SAFETY: `h` is the live desktop handle the caller just opened; `name`/`needed` are live
|
||||
// out-params sized exactly as passed; the call writes at most `nlength` bytes.
|
||||
let ok = unsafe {
|
||||
GetUserObjectInformationW(
|
||||
windows::Win32::Foundation::HANDLE(h.0),
|
||||
UOI_NAME,
|
||||
Some(name.as_mut_ptr().cast()),
|
||||
(name.len() * 2) as u32,
|
||||
Some(&mut needed),
|
||||
)
|
||||
};
|
||||
if ok.is_err() {
|
||||
return false;
|
||||
}
|
||||
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
||||
let name = String::from_utf16_lossy(&name[..len]);
|
||||
!name.eq_ignore_ascii_case("Default")
|
||||
}
|
||||
|
||||
impl Drop for DesktopBinding {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.0.take() {
|
||||
// SAFETY: `h` is our owned desktop handle, closed exactly once here.
|
||||
let _ = unsafe { CloseDesktop(h) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise `hcursor` to straight-alpha RGBA: `(rgba, w, h, hot_x, hot_y)`. `None` on any
|
||||
/// failure (caller keeps the previous shape).
|
||||
fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> RasterOut {
|
||||
// CopyIcon first: the owning process can destroy its HCURSOR between GetCursorInfo and the
|
||||
// reads below; the copy is ours (the OBS/WebRTC guard).
|
||||
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
||||
// icons in user32); CopyIcon yields an owned HICON we destroy below.
|
||||
let Ok(icon) = (unsafe { CopyIcon(HICON(hcursor.0)) }) else {
|
||||
return None;
|
||||
};
|
||||
let mut ii = ICONINFO::default();
|
||||
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps —
|
||||
// both deleted below (GDI-handle leak otherwise).
|
||||
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
||||
let out = if got.is_ok() { convert(&ii) } else { None };
|
||||
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
||||
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
||||
unsafe {
|
||||
let _ = DeleteObject(ii.hbmColor.into());
|
||||
let _ = DeleteObject(ii.hbmMask.into());
|
||||
let _ = DestroyIcon(icon);
|
||||
}
|
||||
out.map(|(rgba, w, h)| {
|
||||
let hot_x = ii.xHotspot.min(w.saturating_sub(1));
|
||||
let hot_y = ii.yHotspot.min(h.saturating_sub(1));
|
||||
(rgba, w, h, hot_x, hot_y)
|
||||
})
|
||||
}
|
||||
|
||||
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
||||
|
||||
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
|
||||
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
|
||||
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
|
||||
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
|
||||
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
|
||||
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
|
||||
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
||||
// icons in user32); CopyIcon yields an owned HICON destroyed below.
|
||||
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
|
||||
let mut ii = ICONINFO::default();
|
||||
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
|
||||
// deleted below (GDI-handle leak otherwise).
|
||||
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
||||
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
|
||||
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
|
||||
let extent = got.is_ok().then_some(()).and_then(|()| {
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
bitmap_extent(ii.hbmColor)
|
||||
} else {
|
||||
let (w, h) = bitmap_extent(ii.hbmMask)?;
|
||||
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
|
||||
}
|
||||
});
|
||||
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
||||
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
||||
unsafe {
|
||||
let _ = DeleteObject(ii.hbmColor.into());
|
||||
let _ = DeleteObject(ii.hbmMask.into());
|
||||
let _ = DestroyIcon(icon);
|
||||
}
|
||||
extent
|
||||
}
|
||||
|
||||
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
|
||||
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
|
||||
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
|
||||
let mut bm = BITMAP::default();
|
||||
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
||||
let n = unsafe {
|
||||
GetObjectW(
|
||||
hbm.into(),
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some((&mut bm as *mut BITMAP).cast()),
|
||||
)
|
||||
};
|
||||
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((bm.bmWidth as u32, bm.bmHeight as u32))
|
||||
}
|
||||
|
||||
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
||||
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
||||
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
||||
/// - monochrome (`hbmColor` null): `hbmMask` is DOUBLE height — AND plane over XOR plane, the
|
||||
/// WebRTC truth table: (0,0) black, (0,1) white, (1,0) transparent, (1,1) invert. Invert
|
||||
/// pixels — unrepresentable in straight alpha — become opaque black with a white outline
|
||||
/// grown into adjacent transparency (the WebRTC approximation; keeps the I-beam legible on
|
||||
/// any background, which the old translucent-gray stand-in did not).
|
||||
fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
||||
// SAFETY: GetDC(None) yields the screen DC, released below on every path; it is only used
|
||||
// as the GetDIBits reference DC.
|
||||
let dc = unsafe { GetDC(None) };
|
||||
let result = (|| {
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
||||
let (w, h) = (color.w as u32, color.h as u32);
|
||||
let mut rgba = bgra_to_rgba(&color.bgra);
|
||||
if alpha_is_empty(&rgba) {
|
||||
// Alpha-less color cursor: transparency lives in the AND mask.
|
||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
||||
if mask.w != color.w || mask.h < color.h {
|
||||
return None;
|
||||
}
|
||||
apply_and_mask_alpha(&mut rgba, &mask.bgra);
|
||||
}
|
||||
Some((rgba, w, h))
|
||||
} else {
|
||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
||||
if mask.h < 2 || mask.h % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
|
||||
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
|
||||
Some((rgba, w as u32, h as u32))
|
||||
}
|
||||
})();
|
||||
// SAFETY: releasing the screen DC obtained above, exactly once.
|
||||
unsafe {
|
||||
ReleaseDC(None, dc);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
const NEIGHBORS: [(i32, i32); 8] = [
|
||||
(-1, -1),
|
||||
(0, -1),
|
||||
(1, -1),
|
||||
(-1, 0),
|
||||
(1, 0),
|
||||
(-1, 1),
|
||||
(0, 1),
|
||||
(1, 1),
|
||||
];
|
||||
|
||||
struct RawBitmap {
|
||||
w: i32,
|
||||
h: i32,
|
||||
/// 32bpp top-down BGRA rows, `w*h*4` (monochrome sources arrive expanded: 0x00/0xFF channels).
|
||||
bgra: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Read any GDI bitmap as 32bpp top-down via `GetDIBits` (which performs the 1bpp→32bpp
|
||||
/// expansion for the mask planes).
|
||||
fn read_bitmap_32(dc: HDC, hbm: HBITMAP) -> Option<RawBitmap> {
|
||||
let mut bm = BITMAP::default();
|
||||
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
||||
let n = unsafe {
|
||||
GetObjectW(
|
||||
hbm.into(),
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some((&mut bm as *mut BITMAP).cast()),
|
||||
)
|
||||
};
|
||||
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
||||
return None; // 512/1024: sanity caps (256² is the wire max; XL accessibility ≤ that)
|
||||
}
|
||||
let (w, h) = (bm.bmWidth, bm.bmHeight);
|
||||
let mut info = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: w,
|
||||
biHeight: -h, // top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut buf = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
// SAFETY: `buf` spans exactly `h` rows of `w` 32bpp pixels as described by `info`; both are
|
||||
// live locals for this synchronous call, `hbm` is a live bitmap not selected into any DC
|
||||
// (fresh GetIconInfo copies).
|
||||
let rows = unsafe {
|
||||
GetDIBits(
|
||||
dc,
|
||||
hbm,
|
||||
0,
|
||||
h as u32,
|
||||
Some(buf.as_mut_ptr().cast()),
|
||||
&mut info,
|
||||
DIB_RGB_COLORS,
|
||||
)
|
||||
};
|
||||
(rows != 0).then_some(RawBitmap { w, h, bgra: buf })
|
||||
}
|
||||
|
||||
fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
|
||||
let mut out = bgra.to_vec();
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
|
||||
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
|
||||
fn alpha_is_empty(rgba: &[u8]) -> bool {
|
||||
rgba.chunks_exact(4).all(|p| p[3] == 0)
|
||||
}
|
||||
|
||||
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
|
||||
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
|
||||
/// channel byte is "set".
|
||||
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
|
||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
|
||||
px[3] = if m[0] != 0 { 0 } else { 0xFF };
|
||||
}
|
||||
}
|
||||
|
||||
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
|
||||
///
|
||||
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
|
||||
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
|
||||
///
|
||||
/// | AND | XOR | meaning | straight-alpha result |
|
||||
/// |-----|-----|-------------|------------------------------------------|
|
||||
/// | 0 | 0 | black | opaque black |
|
||||
/// | 0 | 1 | white | opaque white |
|
||||
/// | 1 | 0 | transparent | fully transparent |
|
||||
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
|
||||
///
|
||||
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
|
||||
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
|
||||
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
|
||||
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
|
||||
///
|
||||
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
|
||||
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
|
||||
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut invert = vec![false; w * h];
|
||||
for i in 0..w * h {
|
||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||
match (a, x) {
|
||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
(true, false) => {} // transparent (already zeroed)
|
||||
(true, true) => {
|
||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||
invert[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
if !invert[(y * w as i32 + x) as usize] {
|
||||
continue;
|
||||
}
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let o = (ny * w as i32 + nx) as usize * 4;
|
||||
if rgba[o + 3] == 0 {
|
||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
|
||||
/// any non-zero channel byte means "bit set".
|
||||
fn plane(bits: &[u8]) -> Vec<u8> {
|
||||
bits.iter()
|
||||
.flat_map(|&b| {
|
||||
let v = if b != 0 { 0xFF } else { 0 };
|
||||
[v, v, v, 0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
|
||||
rgba[i * 4..i * 4 + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
|
||||
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
|
||||
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
|
||||
|
||||
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
|
||||
#[test]
|
||||
fn the_monochrome_truth_table_is_exact() {
|
||||
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
|
||||
let and = plane(&[0, 0, 1, 1]);
|
||||
let xor = plane(&[0, 1, 0, 1]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
|
||||
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
|
||||
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
|
||||
// so the outline claims it — that IS the documented behaviour.
|
||||
assert_eq!(
|
||||
px(&out, 2),
|
||||
OPAQUE_WHITE,
|
||||
"outline grows into adjacent transparency"
|
||||
);
|
||||
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
|
||||
}
|
||||
|
||||
/// Transparency survives when there is no invert pixel next to it.
|
||||
#[test]
|
||||
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let xor = plane(&[0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
for i in 0..4 {
|
||||
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
|
||||
/// repaint a black or white shape pixel.
|
||||
#[test]
|
||||
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
|
||||
// 3×3, invert at the centre, everything else transparent.
|
||||
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 9]);
|
||||
for b in &mut xor[4 * 4..4 * 4 + 3] {
|
||||
*b = 0xFF; // centre pixel's XOR bit
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
|
||||
}
|
||||
|
||||
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
|
||||
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(
|
||||
px(&out, i),
|
||||
OPAQUE_BLACK,
|
||||
"neighbour {i} must not be repainted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
|
||||
#[test]
|
||||
fn the_outline_clips_at_the_edges() {
|
||||
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 4]);
|
||||
for b in &mut xor[0..3] {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK);
|
||||
for i in [1, 2, 3] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the alpha-less colour path ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_empty_alpha_channel_is_detected() {
|
||||
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
|
||||
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
|
||||
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
|
||||
}
|
||||
|
||||
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
|
||||
#[test]
|
||||
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
|
||||
let mut rgba = vec![
|
||||
10, 20, 30, 0, // pixel 0
|
||||
40, 50, 60, 0, // pixel 1
|
||||
];
|
||||
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
|
||||
apply_and_mask_alpha(&mut rgba, &mask);
|
||||
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
|
||||
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
|
||||
}
|
||||
|
||||
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
|
||||
/// side, leaving the tail at whatever alpha it had (the caller has already required
|
||||
/// `mask.h >= color.h`, so this is the belt).
|
||||
#[test]
|
||||
fn a_short_mask_does_not_panic() {
|
||||
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
|
||||
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
|
||||
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
|
||||
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
|
||||
}
|
||||
}
|
||||
@@ -1,866 +0,0 @@
|
||||
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
|
||||
//!
|
||||
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
|
||||
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
|
||||
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
|
||||
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
|
||||
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
|
||||
//! read when a session will not START, and the parent is the part you read when one stops flowing.
|
||||
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
|
||||
//! made more visible to move here.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
|
||||
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
|
||||
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
|
||||
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
|
||||
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
|
||||
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
|
||||
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
|
||||
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
|
||||
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
|
||||
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
|
||||
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
|
||||
/// `design/idd-push-security.md`.
|
||||
///
|
||||
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
|
||||
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
|
||||
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
|
||||
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
|
||||
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
|
||||
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
|
||||
/// pointer targets the heap allocation, not a field.
|
||||
struct SharedObjectSa {
|
||||
sa: SECURITY_ATTRIBUTES,
|
||||
psd: PSECURITY_DESCRIPTOR,
|
||||
}
|
||||
|
||||
impl SharedObjectSa {
|
||||
fn new() -> Result<Self> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
|
||||
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
|
||||
// `psd` is read.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:P(A;;GA;;;SY)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)
|
||||
.context("build SDDL for IDD-push shared objects")?;
|
||||
}
|
||||
Ok(Self {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
},
|
||||
psd,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
|
||||
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
|
||||
&self.sa
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedObjectSa {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
|
||||
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IddPushCapturer {
|
||||
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
||||
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||
pub(super) unsafe fn create_ring_slots(
|
||||
device: &ID3D11Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<Vec<HostSlot>> {
|
||||
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
|
||||
// because `_psd`, the security descriptor backing it, is held in scope alongside.
|
||||
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
|
||||
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
|
||||
unsafe {
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let mut slots = Vec::new();
|
||||
for _ in 0..RING_LEN {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
|
||||
// its format-guard both succeed.
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
|
||||
as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(IDD-push ring slot)")?;
|
||||
let tex = tex.context("null ring texture")?;
|
||||
let res1: IDXGIResource1 = tex.cast()?;
|
||||
let shared = res1
|
||||
.CreateSharedHandle(
|
||||
Some(sa.as_ptr()),
|
||||
DXGI_SHARED_RESOURCE_RW,
|
||||
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
|
||||
)
|
||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(IDD-push ring slot)")?;
|
||||
let srv = srv.context("null slot srv")?;
|
||||
slots.push(HostSlot {
|
||||
tex,
|
||||
mutex,
|
||||
shared,
|
||||
srv,
|
||||
});
|
||||
}
|
||||
Ok(slots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
pf_win_display::display_events::spawn_once();
|
||||
match Self::open_inner(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
}
|
||||
Err(e) => Err((e, keepalive)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||
HighPart: (target.adapter_luid >> 32) as i32,
|
||||
});
|
||||
match Self::open_on(
|
||||
target.clone(),
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
sender.clone(),
|
||||
cursor_sender.clone(),
|
||||
cursor_forward.clone(),
|
||||
) {
|
||||
Ok(me) => Ok(me),
|
||||
Err(e) => {
|
||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||
// exact same mismatch.
|
||||
let driver_luid = e
|
||||
.downcast_ref::<AttachTexFail>()
|
||||
.map(|tf| tf.driver_luid)
|
||||
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
|
||||
let Some(packed) = driver_luid else {
|
||||
return Err(e);
|
||||
};
|
||||
let drv = LUID {
|
||||
LowPart: (packed & 0xffff_ffff) as u32,
|
||||
HighPart: (packed >> 32) as i32,
|
||||
};
|
||||
tracing::warn!(
|
||||
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
let (pw, ph, _hz) = preferred
|
||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
|
||||
.unwrap_or((pw, ph));
|
||||
if (w, h) != (pw, ph) {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated = format!("{pw}x{ph}"),
|
||||
actual = format!("{w}x{h}"),
|
||||
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||
);
|
||||
}
|
||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
|
||||
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
||||
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
||||
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
||||
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
||||
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
|
||||
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
|
||||
// `?`-checked, so every returned interface/handle/view is non-error before use;
|
||||
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
|
||||
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
|
||||
// for the whole block (and frees the descriptor on the way out).
|
||||
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
|
||||
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
|
||||
// created owned section/event for the duration of that synchronous call.
|
||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
|
||||
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
|
||||
// session on a reused/lingering monitor, the driver's default, or the host's global
|
||||
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
|
||||
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
|
||||
// the FP16 ring at all.
|
||||
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
|
||||
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
|
||||
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
|
||||
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
|
||||
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||
if !client_10bit {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
|
||||
virtual display (a physical display forcing HDR?) — PyroWave will likely fail \
|
||||
its first frame; H.26x would emit PQ the SDR-only client never asked for"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
settle_ms = settle.elapsed().as_millis() as u64,
|
||||
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr = client_10bit
|
||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||
// stash/format-guard machinery absorbs).
|
||||
let hdr_settle = Instant::now();
|
||||
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
tracing::debug!(
|
||||
target_id = target.target_id,
|
||||
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||
"IDD push: advanced-color (HDR) enable settle"
|
||||
);
|
||||
}
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||
// Keep the raw observation so Downgrade point D below can say whether the read reported
|
||||
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
|
||||
// causes and different fixes.
|
||||
let observed_hdr =
|
||||
pf_win_display::win_display::advanced_color_enabled(target.target_id);
|
||||
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
want_hdr = true,
|
||||
set_advanced_color_returned = enabled_hdr,
|
||||
observed_hdr = ?observed_hdr,
|
||||
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||
(check the display driver / Windows HDR support on this box). \
|
||||
observed_hdr=Some(false) ⇒ the display reports advanced colour OFF after the \
|
||||
set; None ⇒ the CCD read itself failed"
|
||||
);
|
||||
}
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||
// shared textures to open (it reports its actual render LUID into the header, which
|
||||
// `open_inner` uses to rebind once if this mismatches).
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
let adapter: IDXGIAdapter1 = factory
|
||||
.EnumAdapterByLuid(luid)
|
||||
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||
|
||||
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
|
||||
let map = CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(sa.as_ptr()),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
bytes as u32,
|
||||
PCWSTR::null(),
|
||||
)
|
||||
.context("CreateFileMapping(IDD-push header)")?;
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||
}
|
||||
let section = MappedSection { handle: map, view };
|
||||
let generation = next_generation();
|
||||
let header = section.ptr::<SharedHeader>();
|
||||
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||
(*header).version = VERSION;
|
||||
(*header).generation = generation;
|
||||
(*header).ring_len = RING_LEN;
|
||||
(*header).width = w;
|
||||
(*header).height = h;
|
||||
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
|
||||
// reads this into its `ring_format` and drops any surface that doesn't match.
|
||||
(*header).dxgi_format = ring_fmt.0 as u32;
|
||||
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
|
||||
// stamped before the magic (below), never changed for the ring's life (a mid-session
|
||||
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
|
||||
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
|
||||
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
|
||||
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
|
||||
(*header).target_id = target.target_id;
|
||||
|
||||
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
|
||||
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
|
||||
.context("CreateEvent(IDD-push)")?;
|
||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||
|
||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
|
||||
|
||||
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
|
||||
// (which receives the channel strictly afterwards) can observe MAGIC.
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||
.store(MAGIC, Ordering::Release);
|
||||
|
||||
// Deliver the sealed channel: duplicate header + event + every slot texture into the
|
||||
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||
// the delivery the driver can never attach.
|
||||
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
|
||||
broker
|
||||
.send(
|
||||
target.target_id,
|
||||
generation,
|
||||
HANDLE(section.handle.as_raw_handle()),
|
||||
HANDLE(event.as_raw_handle()),
|
||||
&slots,
|
||||
)
|
||||
.context("deliver IDD-push frame channel to the driver")?;
|
||||
|
||||
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
|
||||
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
|
||||
// so the session degrades to today's composited pointer (and the forwarder simply
|
||||
// never sees a live overlay).
|
||||
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
|
||||
match cursor::CursorShared::create(target.target_id) {
|
||||
Ok(cs) => {
|
||||
// Deliver via the shared helper (also used for RE-delivery after a
|
||||
// driver-side monitor re-arrival destroyed the worker).
|
||||
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
|
||||
.then_some(cs)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"cursor section creation failed — the driver will not declare a \
|
||||
hardware cursor, so this session cannot forward the pointer: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
|
||||
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
|
||||
// the only visible pointer is the one composited here, so force composite mode on.
|
||||
//
|
||||
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
|
||||
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
|
||||
// non-fatally — which is precisely the state that needs this rescue, yet the
|
||||
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
|
||||
// channel, failed to create or deliver it, and then declined to composite, leaving a
|
||||
// cursor-excluded target with NO pointer at all.
|
||||
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
|
||||
if composite_forced {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated_channel = cursor_sender.is_some(),
|
||||
"target carries an irrevocable hardware-cursor declare from an earlier \
|
||||
desktop-mode session and this session has no LIVE cursor channel — the host \
|
||||
composites the pointer into frames (forced, for the session's life). \
|
||||
negotiated_channel=true ⇒ one was negotiated but its creation/delivery failed"
|
||||
);
|
||||
}
|
||||
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
|
||||
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
|
||||
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
|
||||
// Forced-composite sessions need it too — it is their only shape/position source.
|
||||
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
|
||||
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
|
||||
// call CursorShared::create makes) — already inside open_on's unsafe region.
|
||||
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
|
||||
.unwrap_or((0, 0, i32::MAX, i32::MAX));
|
||||
cursor_poll::CursorPoller::spawn(target.target_id, rect)
|
||||
});
|
||||
// Heal the driver's persisted cursor-forward state: a session that died on the
|
||||
// secure desktop (client drops at the lock screen — the common case) leaves the
|
||||
// per-target desired state `false`, and the NEXT session's channel delivery would
|
||||
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
|
||||
// session always starts declared; the secure-desktop guard re-disables if the
|
||||
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
|
||||
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
|
||||
if let Err(e) = fwd(true) {
|
||||
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
wudf_pid = target.wudf_pid,
|
||||
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
mode = format!("{w}x{h}"),
|
||||
display_hdr,
|
||||
client_10bit,
|
||||
want_444,
|
||||
ring_fp16 = display_hdr,
|
||||
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
|
||||
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
|
||||
// 0 here means the hook is inert on this build — the first thing to check if a
|
||||
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
|
||||
// (`dxgi::install_gpu_pref_hook`).
|
||||
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
|
||||
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
||||
to attach + publish"
|
||||
);
|
||||
let mut me = Self {
|
||||
device,
|
||||
context,
|
||||
target_id: target.target_id,
|
||||
section,
|
||||
header,
|
||||
event,
|
||||
broker,
|
||||
width: w,
|
||||
height: h,
|
||||
slots,
|
||||
generation,
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
hdr_pin_warned: false,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
pyro_fence_handle: None,
|
||||
pyro_fence_value: 0,
|
||||
pyro_ring: Vec::new(),
|
||||
pyro_conv: None,
|
||||
pyro_last: None,
|
||||
desc_poller: DescriptorPoller::spawn(
|
||||
target.target_id,
|
||||
DisplayDescriptor {
|
||||
hdr: display_hdr,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
),
|
||||
desc_seq: 0,
|
||||
pending_desc: None,
|
||||
recovering_since: None,
|
||||
last_fresh: Instant::now(),
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
cursor_shared,
|
||||
cursor_poll,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
secure_active: false,
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
cursor_blend_failed: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
last_slot: None,
|
||||
sdr_white_scale: 1.0,
|
||||
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||
// wait for the first compose) until the capturer drops with the session.
|
||||
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||
// it back to the caller for the DDA fallback (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
|
||||
// from the blend (which holds the ring slot's keyed mutex — see
|
||||
// `refresh_sdr_white_scale`). No-op on an SDR composition.
|
||||
me.refresh_sdr_white_scale();
|
||||
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||
// instead of next_frame's 20 s black-then-bail.
|
||||
me.wait_for_attach()?;
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||
///
|
||||
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
|
||||
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
|
||||
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
|
||||
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
|
||||
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
|
||||
/// below — no frame within the window = genuinely broken.
|
||||
fn wait_for_attach(&self) -> Result<()> {
|
||||
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
|
||||
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
|
||||
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
|
||||
// catches from the other end; failing here names the culprit in the same release.
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
|
||||
// pattern as the `driver_status` read below); no reference into the shared region is formed.
|
||||
let stamped = unsafe { (*self.header).target_id };
|
||||
if stamped != self.target_id {
|
||||
bail!(
|
||||
"IDD-push: our ring header names target {stamped} but this capturer serves target \
|
||||
{} — host-side ring↔monitor cross-wire (bug); failing the open",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
|
||||
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
|
||||
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
|
||||
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
|
||||
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
|
||||
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
|
||||
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
|
||||
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
|
||||
// stash path is working.
|
||||
let mut next_kick = Instant::now() + Duration::from_millis(600);
|
||||
loop {
|
||||
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
|
||||
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
|
||||
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
|
||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
if st == DRV_STATUS_TEX_FAIL {
|
||||
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||
// (frame_transport.rs step 2), so it is valid here.
|
||||
let (_, detail, lo, hi) = self.driver_diag();
|
||||
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||
return Err(anyhow::Error::new(AttachTexFail {
|
||||
detail,
|
||||
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||
}));
|
||||
}
|
||||
if st == DRV_STATUS_NO_DEVICE1 {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
the driver has no ID3D11Device1 to open shared resources)"
|
||||
);
|
||||
}
|
||||
if st == DRV_STATUS_BIND_FAIL {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let claimed = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
|
||||
delivered ring names target {claimed}, the monitor is {}) — host \
|
||||
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= next_kick {
|
||||
// Reaching a kick at all means the driver did NOT republish a retained frame
|
||||
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
|
||||
tracing::debug!(
|
||||
target_id = self.target_id,
|
||||
driver_status = st,
|
||||
"IDD push: no first frame after attach delivery — falling back to a synthetic \
|
||||
compose kick (stash-capable drivers republish instantly; old driver?)"
|
||||
);
|
||||
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
|
||||
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
|
||||
// first-frame gate, so no frames are flowing yet.
|
||||
kick_dwm_compose(self.target_id);
|
||||
next_kick = Instant::now() + Duration::from_millis(800);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
bail!(
|
||||
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
|
||||
falling back",
|
||||
self.no_first_frame_diagnosis(st)
|
||||
);
|
||||
}
|
||||
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||
// event, for truth.
|
||||
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
|
||||
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
|
||||
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
|
||||
/// field report burned days for lack of exactly this line. Appends a console-session hint when
|
||||
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
|
||||
fn no_first_frame_diagnosis(&self, st: u32) -> String {
|
||||
let what = match st {
|
||||
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
|
||||
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
|
||||
consumed, so the OS ran no swap-chain worker for this monitor (display not \
|
||||
composed at all: console display-off / modern standby, or the mode commit \
|
||||
never reached the adapter)"
|
||||
.to_string(),
|
||||
DRV_STATUS_OPENED => {
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
|
||||
// (same best-effort diagnostic access as the `driver_status` read in the caller);
|
||||
// no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
match unpack_opened_detail(detail) {
|
||||
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
|
||||
ZERO frames — an undamaged or powered-off desktop, and the compose \
|
||||
kicks didn't bite (synthetic input is blocked on the secure desktop)"
|
||||
.to_string(),
|
||||
Some((offered, mismatched)) => format!(
|
||||
"driver attached and DWM composed {offered} frame(s), but none matched \
|
||||
the ring — {mismatched} dropped for a size/format mismatch (the \
|
||||
display's actual mode differs from what the host sized the ring to: \
|
||||
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
|
||||
),
|
||||
// A pre-detail driver never stamps the live bit — say so rather than guess.
|
||||
None => "driver attached but published nothing; this pf-vdisplay build \
|
||||
predates attach diagnostics, so the cause can't be named — update the \
|
||||
driver for a precise line here"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
other => format!("driver_status={other} (unexpected at this point)"),
|
||||
};
|
||||
match pf_win_display::console_session_mismatch() {
|
||||
Some((own, console)) => format!(
|
||||
"{what} [host is in session {own} but the console is session {console} — display \
|
||||
writes and input kicks cannot work from a non-console session; reconnect the \
|
||||
console or run via the installed service]"
|
||||
),
|
||||
None => what,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||
#[derive(Debug)]
|
||||
struct AttachTexFail {
|
||||
detail: u32,
|
||||
driver_luid: i64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AttachTexFail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||
self.detail,
|
||||
(self.driver_luid >> 32) as i32,
|
||||
(self.driver_luid & 0xffff_ffff) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AttachTexFail {}
|
||||
@@ -31,10 +31,6 @@ pub(super) struct StallWatch {
|
||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||
recent: std::collections::VecDeque<Instant>,
|
||||
cadence: pf_frame::metronome::Metronome,
|
||||
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
|
||||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||
seen: u32,
|
||||
with_os_events: u32,
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -52,8 +48,6 @@ impl StallWatch {
|
||||
Self {
|
||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||
cadence: pf_frame::metronome::Metronome::new(),
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,78 +79,4 @@ impl StallWatch {
|
||||
metronomic: self.cadence.note(now),
|
||||
})
|
||||
}
|
||||
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||||
/// metronomic — name the class of disturbance and its cures.
|
||||
///
|
||||
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
|
||||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
let window = stall.gap + Duration::from_millis(300);
|
||||
let events = now
|
||||
.checked_sub(window)
|
||||
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||||
.unwrap_or_default();
|
||||
self.seen = self.seen.saturating_add(1);
|
||||
if !events.is_empty() {
|
||||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||
}
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
if self.with_os_events * 2 >= self.seen {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
each time. Cures, best-first: that display's OSD 'auto input \
|
||||
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||||
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||||
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||||
suppresses the Windows-side reaction (see connected_inactive for the \
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,17 +140,13 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
/// # Safety
|
||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
|
||||
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||
unsafe {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
return Ok(a);
|
||||
}
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
return Ok(a);
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
|
||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||
@@ -181,12 +177,8 @@ unsafe fn create_nv12(
|
||||
..Default::default()
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
|
||||
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||
unsafe {
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
}
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
tex.context("CreateTexture2D returned a null NV12 texture")
|
||||
}
|
||||
|
||||
@@ -299,23 +299,6 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
|
||||
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
|
||||
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
|
||||
///
|
||||
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
|
||||
/// from that pad's arrival and only falls back to the session default for a pad that never
|
||||
/// declares one, so a client that always declared the detected kind would silently undo the
|
||||
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
|
||||
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
|
||||
/// the user's hands, not the one the host is pretending to have.
|
||||
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
|
||||
match setting {
|
||||
GamepadPref::Auto => physical,
|
||||
explicit => explicit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
|
||||
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
|
||||
/// flatpak sandbox). Cached: the answer can't change while we run.
|
||||
@@ -335,7 +318,6 @@ enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -470,18 +452,6 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
/// Adopt the user's explicit controller-type setting for the session about to start
|
||||
/// (`GamepadPref::Auto` = detect per pad, the default).
|
||||
///
|
||||
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
|
||||
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
|
||||
/// detected kind would silently undo the setting the moment a controller connected. Call it
|
||||
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
|
||||
/// hot-swap a device that already exists.
|
||||
pub fn set_kind_override(&self, pref: GamepadPref) {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -721,10 +691,6 @@ struct Worker {
|
||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||
pinned: Option<String>,
|
||||
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -910,17 +876,10 @@ impl Worker {
|
||||
);
|
||||
return;
|
||||
};
|
||||
let pref = match self.pad_info(id) {
|
||||
// Steam Input's virtual pad standing in front of the Deck's built-in controls (the
|
||||
// only-pad-forwarded case, [`Self::forwarded_ids`]): declare the DECK kind, not the
|
||||
// wrapper's Xbox 360 identity. [`Self::auto_pref`] already resolves the SESSION
|
||||
// default this way, but a current host honors the per-pad arrival over the session
|
||||
// default — so without this the host builds an X-Box 360 pad on a real Deck.
|
||||
Some(p) if p.steam_virtual && is_steam_deck() => GamepadPref::SteamDeck,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Xbox360,
|
||||
};
|
||||
let declared = declared_kind(self.kind_override, pref);
|
||||
let pref = self
|
||||
.pad_info(id)
|
||||
.map(|p| p.pref)
|
||||
.unwrap_or(GamepadPref::Xbox360);
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
@@ -930,13 +889,7 @@ impl Worker {
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
declared.to_u8() as u32,
|
||||
0,
|
||||
index,
|
||||
);
|
||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
||||
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||
@@ -952,13 +905,7 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
pref = ?pref,
|
||||
declared = ?declared,
|
||||
"gamepad forwarding (slot opened)"
|
||||
);
|
||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
||||
self.slots.push(slot);
|
||||
}
|
||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||
@@ -1266,7 +1213,6 @@ impl Worker {
|
||||
self.pinned = key;
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1606,7 +1552,6 @@ impl Worker {
|
||||
menu_open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
kind_override: GamepadPref::Auto,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -1872,38 +1817,6 @@ mod slot_tests {
|
||||
assert_eq!(lowest_free_index(&but_seven), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_setting_is_what_every_pad_declares() {
|
||||
// The regression this pins: the setting used to reach the Hello only, and each pad's
|
||||
// arrival then re-declared the DETECTED kind — which the host honors over the session
|
||||
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
|
||||
GamepadPref::DualShock4
|
||||
);
|
||||
// Every physical pad in a mixed session follows the one explicit choice.
|
||||
for physical in [
|
||||
GamepadPref::DualSense,
|
||||
GamepadPref::Xbox360,
|
||||
GamepadPref::SwitchPro,
|
||||
GamepadPref::SteamDeck,
|
||||
] {
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Xbox360, physical),
|
||||
GamepadPref::Xbox360
|
||||
);
|
||||
}
|
||||
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
|
||||
GamepadPref::DualSense
|
||||
);
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
|
||||
GamepadPref::SteamDeck
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidout_pad_reads_every_variant() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -62,10 +62,6 @@ pub struct GameEntry {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
@@ -284,7 +280,7 @@ mod tests {
|
||||
fn game_entry_decodes_the_wire_shape() {
|
||||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||||
let json = r#"[
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
||||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||||
"launch":{"kind":"steam_appid","value":"570"}},
|
||||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||||
@@ -292,12 +288,7 @@ mod tests {
|
||||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(games.len(), 2);
|
||||
assert_eq!(games[0].id, "steam:570");
|
||||
assert_eq!(games[0].platform.as_deref(), Some("PC"));
|
||||
assert!(games[1].art.portrait.is_none());
|
||||
assert!(
|
||||
games[1].platform.is_none(),
|
||||
"pre-metadata hosts still parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,13 +44,6 @@ pub struct SessionParams {
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
/// Advertise `quic::CLIENT_CAP_CURSOR`: this embedder renders the host cursor locally
|
||||
/// (the presenter's cursor channel, design/remote-desktop-sweep.md M2), so the host may
|
||||
/// stop compositing the pointer into the video. Only set when the embedder actually
|
||||
/// draws it (the SDL presenter in desktop mouse mode) — a session that advertises it
|
||||
/// without rendering streams with NO visible cursor. The host answers `HOST_CAP_CURSOR`
|
||||
/// when its capture can forward (Linux portal, not gamescope/Windows).
|
||||
pub cursor_forward: bool,
|
||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||
/// `video::Decoder::new`).
|
||||
pub decoder: String,
|
||||
@@ -262,11 +255,6 @@ fn pump(
|
||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||
if params.cursor_forward {
|
||||
punktfunk_core::quic::CLIENT_CAP_CURSOR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
params.launch.clone(),
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
|
||||
@@ -212,40 +212,6 @@ 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.
|
||||
@@ -359,11 +325,6 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
|
||||
"Client and host versions don't match — update both to the same release.".into()
|
||||
}
|
||||
R::Busy => "The host is busy with another session.".into(),
|
||||
R::SetupFailed => {
|
||||
"The host accepted the connection but couldn't start the stream — the host's log \
|
||||
(web console → Log) has the cause."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
//! 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 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
|
||||
//! 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
|
||||
//! 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).
|
||||
//!
|
||||
@@ -223,10 +222,9 @@ unsafe extern "C" fn queue_unlock_cb(ud: *mut c_void) {
|
||||
unsafe { (*(ud as *const crate::video::QueueLock)).unlock() }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub struct PyroWavePlanarFrame {
|
||||
/// Raw `VkImageView`s (Y, Cb, Cr) for the presenter's planar CSC sampling.
|
||||
pub views: [u64; 3],
|
||||
@@ -254,8 +252,7 @@ struct RetiredRing {
|
||||
retired_at: Instant,
|
||||
}
|
||||
|
||||
/// One decode-output plane (`fmt` = R8, or R16 on a 10-bit session): storage (decode
|
||||
/// writes) + sampled (presenter CSC).
|
||||
/// One decode-output plane: R8, storage (decode writes) + sampled (presenter CSC).
|
||||
unsafe fn make_plane(
|
||||
device: &ash::Device,
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -542,8 +539,7 @@ impl PyroWaveDecoder {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Plane-set ring: 3 single-component planes (R8; R16 on a 10-bit session),
|
||||
// storage (decode writes) + sampled (presenter CSC).
|
||||
// Plane-set ring: 3 × R8, storage (decode writes) + sampled (presenter CSC).
|
||||
let mem_props = instance.get_physical_device_memory_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
@@ -877,26 +873,12 @@ 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: fmt,
|
||||
view_format: fmt,
|
||||
image_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
view_format: pw::VkFormat_VK_FORMAT_R8_UNORM,
|
||||
mip_level: 0,
|
||||
layer: 0,
|
||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
@@ -904,16 +886,11 @@ 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], cw, ch),
|
||||
plane(self.ring[slot].imgs[2], cw, ch),
|
||||
plane(self.ring[slot].imgs[1], w / 2, h / 2),
|
||||
plane(self.ring[slot].imgs[2], w / 2, h / 2),
|
||||
],
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
|
||||
@@ -66,31 +66,7 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
|
||||
/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it;
|
||||
/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship
|
||||
/// driver+host together, as ever.
|
||||
/// v5: ADDITIVE — the IddCx HARDWARE CURSOR channel (remote-desktop-sweep M2c):
|
||||
/// [`control::AddRequest::hw_cursor`] (the former tail `_reserved` — same size, same offsets)
|
||||
/// asks the driver to declare a hardware cursor for this monitor (DWM then EXCLUDES the pointer
|
||||
/// from the desktop frame and delivers shape/position out-of-band), and
|
||||
/// [`control::IOCTL_SET_CURSOR_CHANNEL`] delivers the host-created [`cursor::CursorShm`] section
|
||||
/// the driver's cursor thread seqlock-publishes into. Nothing existing changed; the host gates
|
||||
/// the feature on the handshake-reported version (`>= 5`) and keeps composited-cursor behavior
|
||||
/// against older drivers.
|
||||
/// v6: ADDITIVE — [`control::IOCTL_SET_CURSOR_FORWARD`] (the mid-stream cursor-render flip,
|
||||
/// remote-desktop-sweep §8): the client's mouse-model flip (un)declares the hardware cursor on a
|
||||
/// LIVE monitor, so the capture mouse model gets DWM's composited pointer back (full fidelity)
|
||||
/// and the desktop model gets exclusion + forwarding. Nothing existing changed; against a v5
|
||||
/// driver the unknown IOCTL fails and the host logs + keeps the declared-at-ADD behavior.
|
||||
/// v6 tail ext (no bump, the `AddRequest` luminance-tail discipline):
|
||||
/// [`control::AddReply::cursor_excluded`] — the driver reports whether its ADAPTER already
|
||||
/// carries a hardware-cursor declare from an earlier session. A declare is IRREVOCABLE
|
||||
/// (remote-desktop-sweep §8.6, proven on-glass) and its exclusion reaches EVERY later monitor of
|
||||
/// the adapter, not just the declaring target (on-glass 2026-07-23: a declare on one target left
|
||||
/// a different client's fresh target cursor-less): DWM never composites the software cursor back
|
||||
/// into any of the adapter's frames until the adapter resets. The host uses the flag to
|
||||
/// self-composite the pointer (GDI poller + blend) in sessions that never negotiate the
|
||||
/// cursor channel — without it those sessions are silently cursor-less. Both skews degrade
|
||||
/// cleanly: an old driver writes only the 20-byte reply prefix (host reads `0` = unknown/clean),
|
||||
/// an old host retrieves a 20-byte buffer (driver writes just the prefix).
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
|
||||
/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on
|
||||
/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the
|
||||
@@ -134,22 +110,6 @@ pub mod control {
|
||||
/// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3
|
||||
/// driver fails this unknown IOCTL → the host falls back to the re-arrival resize.
|
||||
pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907);
|
||||
/// Deliver a monitor's hardware-cursor channel (v5): the handle VALUE of the unnamed
|
||||
/// [`cursor::CursorShm`](crate::cursor) file mapping the host duplicated into the WUDFHost
|
||||
/// (same delivery model as [`IOCTL_SET_FRAME_CHANNEL`], no event — the host polls the
|
||||
/// seqlock at its encode-tick pace). Sent once after ADD, only for a monitor whose
|
||||
/// [`AddRequest::hw_cursor`] was set. The driver maps it, calls
|
||||
/// `IddCxMonitorSetupHardwareCursor`, and starts its cursor-query thread. Input
|
||||
/// [`SetCursorChannelRequest`].
|
||||
pub const IOCTL_SET_CURSOR_CHANNEL: u32 = ctl_code(0x908);
|
||||
/// Flip a LIVE monitor's hardware-cursor declaration (v6, the mid-stream cursor-render
|
||||
/// flip): `enable = 1` re-declares (`IddCxMonitorSetupHardwareCursor` — DWM excludes the
|
||||
/// pointer, the query/shape machinery resumes), `enable = 0` un-declares (the driver stops
|
||||
/// re-declaring on mode commits and asks the OS to revert to the software cursor — DWM
|
||||
/// composites the pointer into the frame, the pre-channel behavior the capture mouse
|
||||
/// model wants). Only meaningful for a monitor whose cursor channel was delivered. Input
|
||||
/// [`SetCursorForwardRequest`].
|
||||
pub const IOCTL_SET_CURSOR_FORWARD: u32 = ctl_code(0x909);
|
||||
|
||||
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
|
||||
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
|
||||
@@ -187,13 +147,9 @@ pub mod control {
|
||||
/// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance
|
||||
/// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown.
|
||||
pub min_luminance_millinits: u32,
|
||||
/// Non-zero = declare an IddCx HARDWARE CURSOR for this monitor (v5, remote-desktop-sweep
|
||||
/// M2c): DWM stops compositing the pointer into the frame and the driver publishes
|
||||
/// shape/position into the [`cursor::CursorShm`](crate::cursor) section delivered by
|
||||
/// [`IOCTL_SET_CURSOR_CHANNEL`]. Byte-compatible with the old tail `_reserved` (offset 36):
|
||||
/// an un-upgraded driver ignores it (cursor stays composited — the host already gates on
|
||||
/// the handshake version, this is defense in depth), an un-upgraded host sends `0` (off).
|
||||
pub hw_cursor: u32,
|
||||
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding);
|
||||
/// free expansion room for the next appended field.
|
||||
pub _reserved: u32,
|
||||
}
|
||||
|
||||
/// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded
|
||||
@@ -218,22 +174,8 @@ pub mod control {
|
||||
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
|
||||
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
|
||||
pub wudf_pid: u32,
|
||||
/// Non-zero = the ADAPTER already carries an IRREVOCABLE hardware-cursor declare from an
|
||||
/// earlier session (remote-desktop-sweep §8.6; reach is adapter-wide, not per-target —
|
||||
/// on-glass 2026-07-23): DWM excludes the pointer from every frame on every monitor until
|
||||
/// the adapter resets, and a session without the cursor channel must
|
||||
/// self-composite (GDI poller + blend) or stream a cursor-less desktop. Appended after
|
||||
/// [`ADD_REPLY_LEGACY_SIZE`] under the same dual-size discipline as the `AddRequest`
|
||||
/// luminance tail: an un-upgraded driver writes only the legacy prefix (the host's
|
||||
/// zero-initialized buffer then reads `0` = unknown/clean), an un-upgraded host retrieves a
|
||||
/// legacy-size buffer (the driver writes just the prefix).
|
||||
pub cursor_excluded: u32,
|
||||
}
|
||||
|
||||
/// [`AddReply`]'s size before the `cursor_excluded` tail — the prefix an un-upgraded driver
|
||||
/// writes and an un-upgraded host retrieves (see the field docs).
|
||||
pub const ADD_REPLY_LEGACY_SIZE: usize = 20;
|
||||
|
||||
/// `IOCTL_REMOVE` input.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
@@ -311,29 +253,6 @@ pub mod control {
|
||||
/// at the compile-time maximum; `ring_len` says how many entries are live).
|
||||
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
|
||||
|
||||
/// `IOCTL_SET_CURSOR_CHANNEL` input (v5): the hardware-cursor section for one monitor.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct SetCursorChannelRequest {
|
||||
/// The OS target id from [`AddReply`] — which monitor this channel belongs to.
|
||||
pub target_id: u32,
|
||||
pub _pad: u32,
|
||||
/// The [`cursor::CursorShm`](crate::cursor) file-mapping handle VALUE, already duplicated
|
||||
/// into the driver's WUDFHost process ([`AddReply::wudf_pid`]).
|
||||
pub header_handle: u64,
|
||||
}
|
||||
|
||||
/// `IOCTL_SET_CURSOR_FORWARD` input (v6): the mid-stream cursor-render flip for one monitor.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct SetCursorForwardRequest {
|
||||
/// The OS target id from [`AddReply`] — which monitor to flip.
|
||||
pub target_id: u32,
|
||||
/// `1` = declare the hardware cursor (exclude + forward), `0` = un-declare (DWM
|
||||
/// composites — the capture mouse model).
|
||||
pub enable: u32,
|
||||
}
|
||||
|
||||
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
||||
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
||||
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||
@@ -350,18 +269,13 @@ pub mod control {
|
||||
assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE);
|
||||
assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28);
|
||||
assert!(offset_of!(AddRequest, min_luminance_millinits) == 32);
|
||||
// v5: the former tail `_reserved` — same offset, same total size (rename-only).
|
||||
assert!(offset_of!(AddRequest, hw_cursor) == 36);
|
||||
assert!(size_of::<AddRequest>() == 40);
|
||||
|
||||
assert!(size_of::<AddReply>() == 24);
|
||||
assert!(size_of::<AddReply>() == 20);
|
||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||
assert!(offset_of!(AddReply, target_id) == 8);
|
||||
assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
|
||||
assert!(offset_of!(AddReply, wudf_pid) == 16);
|
||||
// The cursor-excluded tail starts exactly at the legacy boundary (prefix-compat).
|
||||
assert!(offset_of!(AddReply, cursor_excluded) == ADD_REPLY_LEGACY_SIZE);
|
||||
|
||||
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
|
||||
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
|
||||
@@ -374,13 +288,6 @@ pub mod control {
|
||||
assert!(size_of::<RemoveRequest>() == 8);
|
||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||
|
||||
assert!(size_of::<SetCursorChannelRequest>() == 16);
|
||||
assert!(offset_of!(SetCursorChannelRequest, target_id) == 0);
|
||||
assert!(offset_of!(SetCursorChannelRequest, header_handle) == 8);
|
||||
assert!(size_of::<SetCursorForwardRequest>() == 8);
|
||||
assert!(offset_of!(SetCursorForwardRequest, target_id) == 0);
|
||||
assert!(offset_of!(SetCursorForwardRequest, enable) == 4);
|
||||
|
||||
assert!(size_of::<UpdateModesRequest>() == 24);
|
||||
assert!(offset_of!(UpdateModesRequest, session_id) == 0);
|
||||
assert!(offset_of!(UpdateModesRequest, width) == 8);
|
||||
@@ -1053,84 +960,6 @@ pub mod mouse {
|
||||
};
|
||||
}
|
||||
|
||||
/// The v5 hardware-cursor channel (remote-desktop-sweep M2c): one unnamed file mapping per
|
||||
/// monitor, host-created, delivered by handle value ([`control::IOCTL_SET_CURSOR_CHANNEL`]).
|
||||
/// The DRIVER's cursor thread (woken by its IddCx `hNewCursorDataAvailable` event) seqlock-writes
|
||||
/// shape + position + visibility; the HOST reads at its encode-tick pace — no event crosses the
|
||||
/// boundary. Writer: bump [`CursorShm::seq`] to ODD, write fields (+ shape bytes when the OS said
|
||||
/// the shape changed), bump to EVEN. Reader: read seq (retry while odd), copy, re-read seq —
|
||||
/// unchanged ⇒ consistent snapshot. Position-only updates never touch the shape bytes, so a
|
||||
/// reader that skips unchanged `shape_id`s never copies torn pixels.
|
||||
pub mod cursor {
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
/// First field of [`CursorShm`] — `b"PFCU"` little-endian; anything else = not attached yet.
|
||||
pub const CURSOR_MAGIC: u32 = u32::from_le_bytes(*b"PFCU");
|
||||
|
||||
/// Max cursor side (px) the driver declares to the OS (`IDDCX_CURSOR_CAPS::MaxX/MaxY`) and
|
||||
/// the section's shape buffer is sized for. Windows XL accessibility cursors top out here;
|
||||
/// the host's wire forwarder downscales to its own cap anyway.
|
||||
pub const CURSOR_SHAPE_MAX: u32 = 256;
|
||||
|
||||
/// Shape-buffer bytes: 32-bpp at the declared max.
|
||||
pub const CURSOR_SHAPE_BYTES: usize = (CURSOR_SHAPE_MAX * CURSOR_SHAPE_MAX * 4) as usize;
|
||||
|
||||
/// Byte offset of the shape pixels inside the section (one cache-line-ish header).
|
||||
pub const CURSOR_SHAPE_OFFSET: usize = 64;
|
||||
|
||||
/// Total section size.
|
||||
pub const CURSOR_SHM_SIZE: usize = CURSOR_SHAPE_OFFSET + CURSOR_SHAPE_BYTES;
|
||||
|
||||
/// `IDDCX_CURSOR_SHAPE_TYPE` values mirrored for the host (the driver writes the OS value
|
||||
/// verbatim into [`CursorShm::cursor_type`]).
|
||||
pub const CURSOR_TYPE_MASKED_COLOR: u32 = 1;
|
||||
pub const CURSOR_TYPE_ALPHA: u32 = 2;
|
||||
|
||||
/// The section header (the shape pixels follow at [`CURSOR_SHAPE_OFFSET`]). `x`/`y` are the
|
||||
/// shape's TOP-LEFT in desktop coordinates (the IddCx `IDARG_OUT_QUERY_HWCURSOR::X/Y`
|
||||
/// convention — position − hotspot, can be negative); `shape_id` is the OS's per-set counter
|
||||
/// (bumps on every shape set, the overlay serial); pixels are the OS's 32-bpp rows at
|
||||
/// `pitch` bytes (BGRA for ALPHA; color+mask for MASKED_COLOR — the host converts).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct CursorShm {
|
||||
pub magic: u32,
|
||||
/// Seqlock: odd = writer mid-update.
|
||||
pub seq: u32,
|
||||
pub visible: u32,
|
||||
pub cursor_type: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub shape_id: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pitch: u32,
|
||||
pub hot_x: u32,
|
||||
pub hot_y: u32,
|
||||
/// Reserved expansion room up to [`CURSOR_SHAPE_OFFSET`].
|
||||
pub _reserved: [u32; 4],
|
||||
}
|
||||
|
||||
// Layout is load-bearing across the process boundary — pin it.
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
assert!(size_of::<CursorShm>() == 64);
|
||||
assert!(size_of::<CursorShm>() <= CURSOR_SHAPE_OFFSET);
|
||||
assert!(offset_of!(CursorShm, magic) == 0);
|
||||
assert!(offset_of!(CursorShm, seq) == 4);
|
||||
assert!(offset_of!(CursorShm, visible) == 8);
|
||||
assert!(offset_of!(CursorShm, cursor_type) == 12);
|
||||
assert!(offset_of!(CursorShm, x) == 16);
|
||||
assert!(offset_of!(CursorShm, y) == 20);
|
||||
assert!(offset_of!(CursorShm, shape_id) == 24);
|
||||
assert!(offset_of!(CursorShm, width) == 28);
|
||||
assert!(offset_of!(CursorShm, height) == 32);
|
||||
assert!(offset_of!(CursorShm, pitch) == 36);
|
||||
assert!(offset_of!(CursorShm, hot_x) == 40);
|
||||
assert!(offset_of!(CursorShm, hot_y) == 44);
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1250,7 +1079,7 @@ mod tests {
|
||||
max_luminance_nits: 800,
|
||||
max_frame_avg_nits: 400,
|
||||
min_luminance_millinits: 50, // 0.05 nits
|
||||
hw_cursor: 1,
|
||||
_reserved: 0,
|
||||
};
|
||||
let bytes = bytemuck::bytes_of(&req);
|
||||
assert_eq!(bytes.len(), 40);
|
||||
@@ -1280,19 +1109,14 @@ mod tests {
|
||||
target_id: 262,
|
||||
resolved_monitor_id: 7,
|
||||
wudf_pid: 4242,
|
||||
cursor_excluded: 1,
|
||||
};
|
||||
let rbytes = bytemuck::bytes_of(&reply);
|
||||
assert_eq!(rbytes.len(), 24);
|
||||
assert_eq!(rbytes.len(), 20);
|
||||
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
|
||||
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
|
||||
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
|
||||
// The v2 duplication-target pid trails at offset 16.
|
||||
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
|
||||
// The cursor-excluded tail rides after the legacy boundary; an un-upgraded driver writes
|
||||
// only the prefix, so a zero-filled tail reads as "unknown/clean" (see the field docs).
|
||||
assert_eq!(rbytes[20..24], 1u32.to_le_bytes());
|
||||
assert_eq!(control::ADD_REPLY_LEGACY_SIZE, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1337,39 +1161,11 @@ mod tests {
|
||||
req
|
||||
);
|
||||
assert_eq!(bytes[8..12], 2560u32.to_le_bytes());
|
||||
// The compat window: v4–v6 are additive over v3, so the host floor stays at 3.
|
||||
assert_eq!(PROTOCOL_VERSION, 6);
|
||||
// The compat window: v4 is additive over v3, so the host floor stays one below.
|
||||
assert_eq!(PROTOCOL_VERSION, 4);
|
||||
assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_shm_layout_is_pinned() {
|
||||
use cursor::*;
|
||||
// The header must leave the shape offset intact whatever grows inside `_reserved`.
|
||||
assert_eq!(core::mem::size_of::<CursorShm>(), 64);
|
||||
assert_eq!(CURSOR_SHM_SIZE, 64 + 256 * 256 * 4);
|
||||
assert_eq!(CURSOR_MAGIC, u32::from_le_bytes(*b"PFCU"));
|
||||
// Seqlock snapshot discipline survives a bytemuck roundtrip.
|
||||
let hdr = CursorShm {
|
||||
magic: CURSOR_MAGIC,
|
||||
seq: 2,
|
||||
visible: 1,
|
||||
cursor_type: CURSOR_TYPE_ALPHA,
|
||||
x: -3,
|
||||
y: 7,
|
||||
shape_id: 42,
|
||||
width: 32,
|
||||
height: 32,
|
||||
pitch: 128,
|
||||
hot_x: 4,
|
||||
hot_y: 5,
|
||||
_reserved: [0; 4],
|
||||
};
|
||||
let bytes = bytemuck::bytes_of(&hdr);
|
||||
assert_eq!(*bytemuck::from_bytes::<CursorShm>(bytes), hdr);
|
||||
assert_eq!(bytes[16..20], (-3i32).to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_names_and_magics_are_stable() {
|
||||
assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
|
||||
|
||||
@@ -7,45 +7,6 @@
|
||||
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
|
||||
@@ -112,7 +73,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
@@ -249,16 +210,9 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
|
||||
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
|
||||
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
|
||||
/// matters).
|
||||
///
|
||||
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
|
||||
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
|
||||
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
|
||||
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
|
||||
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
@@ -268,6 +222,10 @@ pub struct EncoderCaps {
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
@@ -297,22 +255,6 @@ 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. That call is wired now — the
|
||||
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
|
||||
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
|
||||
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
|
||||
/// open-time fallbacks the plan can't see.
|
||||
pub blends_cursor: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
@@ -339,10 +281,11 @@ pub trait Encoder: Send {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
|
||||
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
|
||||
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
|
||||
/// Default: no optional capabilities (the software / libavcodec backends).
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
@@ -350,12 +293,10 @@ pub trait Encoder: Send {
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
|
||||
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
|
||||
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
|
||||
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
|
||||
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
|
||||
/// this is a bonus for stock decoders, never the primary channel.
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
@@ -372,15 +313,8 @@ 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. `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.
|
||||
/// 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.
|
||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -426,16 +360,6 @@ 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
|
||||
@@ -455,20 +379,6 @@ 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<()>;
|
||||
}
|
||||
|
||||
@@ -503,17 +413,6 @@ 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
|
||||
@@ -527,35 +426,6 @@ 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
|
||||
@@ -584,26 +454,6 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -611,31 +461,6 @@ 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());
|
||||
@@ -652,26 +477,6 @@ 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] {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Cursor-overlay blend kernels for the CUDA/NVENC path (cursor-as-metadata). The cursor bitmap is
|
||||
// straight-alpha RGBA, row-packed (stride = curW*4). Blended into the encoder-OWNED NVENC input
|
||||
// surface — never the compositor's dmabuf. One thread per cursor pixel (ARGB / YUV444) or per 2x2
|
||||
// chroma block (NV12). Coefficients are BT.709 limited, matching rgb2yuv.comp so the cursor colour
|
||||
// matches the rest of the frame regardless of which zero-copy backend encodes it.
|
||||
//
|
||||
// Build (regenerate cursor_blend.ptx after editing):
|
||||
// nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx
|
||||
// PTX is JIT'd by the driver forward to the actual GPU, so a compute_75 (Turing) baseline runs on
|
||||
// every Turing-or-newer NVENC GPU. (CUDA 13's nvcc no longer targets pre-Turing archs.)
|
||||
|
||||
typedef unsigned char u8;
|
||||
|
||||
__device__ __forceinline__ u8 blend8(int dst, int src, int a) {
|
||||
return (u8)((src * a + dst * (255 - a)) / 255);
|
||||
}
|
||||
|
||||
// Packed 4-byte surface. NVENC's ARGB format stores bytes B,G,R,A in memory; the cursor is R,G,B,A.
|
||||
extern "C" __global__ void blend_argb(
|
||||
u8* surf, int pitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
if (cx >= curW || cy >= curH) return;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) return;
|
||||
u8* d = surf + (size_t)py * pitch + (size_t)px * 4;
|
||||
d[0] = blend8(d[0], s[2], a); // B <- cursor B
|
||||
d[1] = blend8(d[1], s[1], a); // G <- cursor G
|
||||
d[2] = blend8(d[2], s[0], a); // R <- cursor R
|
||||
}
|
||||
|
||||
// Planar YUV444: three full-res planes stacked at base, base+plane, base+2*plane (plane=pitch*surfH).
|
||||
extern "C" __global__ void blend_yuv444(
|
||||
u8* base, int pitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int cx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int cy = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
if (cx >= curW || cy >= curH) return;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) return;
|
||||
float R = s[0], G = s[1], B = s[2];
|
||||
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||
int U = (int)(128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B + 0.5f);
|
||||
int V = (int)(128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B + 0.5f);
|
||||
size_t plane = (size_t)pitch * surfH;
|
||||
u8* yp = base + (size_t)py * pitch + px;
|
||||
u8* up = base + plane + (size_t)py * pitch + px;
|
||||
u8* vp = base + 2 * plane + (size_t)py * pitch + px;
|
||||
*yp = blend8(*yp, Y, a);
|
||||
*up = blend8(*up, U, a);
|
||||
*vp = blend8(*vp, V, a);
|
||||
}
|
||||
|
||||
// NV12: full-res Y plane + interleaved half-res UV plane. One thread per 2x2 luma block; each blends
|
||||
// up to four Y samples and one (alpha-weighted) UV sample.
|
||||
extern "C" __global__ void blend_nv12(
|
||||
u8* yb, int yPitch, u8* uvb, int uvPitch, int surfW, int surfH,
|
||||
const u8* cur, int curW, int curH, int ox, int oy)
|
||||
{
|
||||
int bx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
int by = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
int base_cx = bx * 2, base_cy = by * 2;
|
||||
if (base_cx >= curW || base_cy >= curH) return;
|
||||
float ua = 0.0f, va = 0.0f, wa = 0.0f;
|
||||
int cnt = 0;
|
||||
for (int j = 0; j < 2; j++) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
int cx = base_cx + i, cy = base_cy + j;
|
||||
if (cx >= curW || cy >= curH) continue;
|
||||
int px = ox + cx, py = oy + cy;
|
||||
if (px < 0 || py < 0 || px >= surfW || py >= surfH) continue;
|
||||
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
|
||||
int a = s[3];
|
||||
if (a == 0) continue;
|
||||
float R = s[0], G = s[1], B = s[2];
|
||||
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
|
||||
u8* yp = yb + (size_t)py * yPitch + px;
|
||||
*yp = blend8(*yp, Y, a);
|
||||
ua += (128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B) * a;
|
||||
va += (128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B) * a;
|
||||
wa += a;
|
||||
cnt++;
|
||||
}
|
||||
}
|
||||
if (wa <= 0.0f || cnt == 0) return;
|
||||
// The chroma sample covering this block's top-left surface pixel.
|
||||
int uvx = (ox + base_cx) / 2;
|
||||
int uvy = (oy + base_cy) / 2;
|
||||
if (uvx < 0 || uvy < 0 || uvx * 2 >= surfW || uvy * 2 >= surfH) return;
|
||||
int U = (int)(ua / wa + 0.5f);
|
||||
int V = (int)(va / wa + 0.5f);
|
||||
int amean = (int)(wa / cnt + 0.5f);
|
||||
u8* uv = uvb + (size_t)uvy * uvPitch + (size_t)uvx * 2;
|
||||
uv[0] = blend8(uv[0], U, amean);
|
||||
uv[1] = blend8(uv[1], V, amean);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
//
|
||||
// Generated by NVIDIA NVVM Compiler
|
||||
//
|
||||
// Compiler Build ID: CL-38244171
|
||||
// Cuda compilation tools, release 13.3, V13.3.73
|
||||
// Based on NVVM 7.0.1
|
||||
//
|
||||
|
||||
.version 9.3
|
||||
.target sm_75
|
||||
.address_size 64
|
||||
|
||||
// .globl blend_argb
|
||||
|
||||
.visible .entry blend_argb(
|
||||
.param .u64 blend_argb_param_0,
|
||||
.param .u32 blend_argb_param_1,
|
||||
.param .u32 blend_argb_param_2,
|
||||
.param .u32 blend_argb_param_3,
|
||||
.param .u64 blend_argb_param_4,
|
||||
.param .u32 blend_argb_param_5,
|
||||
.param .u32 blend_argb_param_6,
|
||||
.param .u32 blend_argb_param_7,
|
||||
.param .u32 blend_argb_param_8
|
||||
)
|
||||
{
|
||||
.reg .pred %p<10>;
|
||||
.reg .b16 %rs<2>;
|
||||
.reg .b32 %r<34>;
|
||||
.reg .b64 %rd<17>;
|
||||
|
||||
|
||||
ld.param.u64 %rd2, [blend_argb_param_0];
|
||||
ld.param.u32 %r5, [blend_argb_param_1];
|
||||
ld.param.u32 %r6, [blend_argb_param_2];
|
||||
ld.param.u32 %r7, [blend_argb_param_3];
|
||||
ld.param.u64 %rd3, [blend_argb_param_4];
|
||||
ld.param.u32 %r8, [blend_argb_param_5];
|
||||
ld.param.u32 %r11, [blend_argb_param_6];
|
||||
ld.param.u32 %r9, [blend_argb_param_7];
|
||||
ld.param.u32 %r10, [blend_argb_param_8];
|
||||
mov.u32 %r12, %ntid.x;
|
||||
mov.u32 %r13, %ctaid.x;
|
||||
mov.u32 %r14, %tid.x;
|
||||
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||
mov.u32 %r15, %ntid.y;
|
||||
mov.u32 %r16, %ctaid.y;
|
||||
mov.u32 %r17, %tid.y;
|
||||
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||
setp.ge.s32 %p1, %r1, %r8;
|
||||
setp.ge.s32 %p2, %r2, %r11;
|
||||
or.pred %p3, %p1, %p2;
|
||||
@%p3 bra $L__BB0_4;
|
||||
|
||||
add.s32 %r3, %r1, %r9;
|
||||
add.s32 %r4, %r2, %r10;
|
||||
or.b32 %r18, %r4, %r3;
|
||||
setp.lt.s32 %p4, %r18, 0;
|
||||
setp.ge.s32 %p5, %r3, %r6;
|
||||
or.pred %p6, %p5, %p4;
|
||||
setp.ge.s32 %p7, %r4, %r7;
|
||||
or.pred %p8, %p7, %p6;
|
||||
@%p8 bra $L__BB0_4;
|
||||
|
||||
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||
mul.wide.s32 %rd4, %r19, 4;
|
||||
cvta.to.global.u64 %rd5, %rd3;
|
||||
add.s64 %rd1, %rd5, %rd4;
|
||||
ld.global.u8 %rs1, [%rd1+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB0_4;
|
||||
|
||||
cvt.u32.u16 %r20, %rs1;
|
||||
mul.wide.s32 %rd6, %r4, %r5;
|
||||
mul.wide.s32 %rd7, %r3, 4;
|
||||
add.s64 %rd8, %rd6, %rd7;
|
||||
cvta.to.global.u64 %rd9, %rd2;
|
||||
add.s64 %rd10, %rd9, %rd8;
|
||||
ld.global.u8 %r21, [%rd10];
|
||||
ld.global.u8 %r22, [%rd1+2];
|
||||
xor.b32 %r23, %r20, 255;
|
||||
mul.lo.s32 %r24, %r23, %r21;
|
||||
mad.lo.s32 %r25, %r22, %r20, %r24;
|
||||
mul.wide.u32 %rd11, %r25, -2139062143;
|
||||
shr.u64 %rd12, %rd11, 39;
|
||||
st.global.u8 [%rd10], %rd12;
|
||||
ld.global.u8 %r26, [%rd10+1];
|
||||
ld.global.u8 %r27, [%rd1+1];
|
||||
mul.lo.s32 %r28, %r23, %r26;
|
||||
mad.lo.s32 %r29, %r27, %r20, %r28;
|
||||
mul.wide.u32 %rd13, %r29, -2139062143;
|
||||
shr.u64 %rd14, %rd13, 39;
|
||||
st.global.u8 [%rd10+1], %rd14;
|
||||
ld.global.u8 %r30, [%rd10+2];
|
||||
ld.global.u8 %r31, [%rd1];
|
||||
mul.lo.s32 %r32, %r23, %r30;
|
||||
mad.lo.s32 %r33, %r31, %r20, %r32;
|
||||
mul.wide.u32 %rd15, %r33, -2139062143;
|
||||
shr.u64 %rd16, %rd15, 39;
|
||||
st.global.u8 [%rd10+2], %rd16;
|
||||
|
||||
$L__BB0_4:
|
||||
ret;
|
||||
|
||||
}
|
||||
// .globl blend_yuv444
|
||||
.visible .entry blend_yuv444(
|
||||
.param .u64 blend_yuv444_param_0,
|
||||
.param .u32 blend_yuv444_param_1,
|
||||
.param .u32 blend_yuv444_param_2,
|
||||
.param .u32 blend_yuv444_param_3,
|
||||
.param .u64 blend_yuv444_param_4,
|
||||
.param .u32 blend_yuv444_param_5,
|
||||
.param .u32 blend_yuv444_param_6,
|
||||
.param .u32 blend_yuv444_param_7,
|
||||
.param .u32 blend_yuv444_param_8
|
||||
)
|
||||
{
|
||||
.reg .pred %p<10>;
|
||||
.reg .b16 %rs<5>;
|
||||
.reg .f32 %f<16>;
|
||||
.reg .b32 %r<49>;
|
||||
.reg .b64 %rd<14>;
|
||||
|
||||
|
||||
ld.param.u64 %rd2, [blend_yuv444_param_0];
|
||||
ld.param.u32 %r5, [blend_yuv444_param_1];
|
||||
ld.param.u32 %r6, [blend_yuv444_param_2];
|
||||
ld.param.u32 %r7, [blend_yuv444_param_3];
|
||||
ld.param.u64 %rd3, [blend_yuv444_param_4];
|
||||
ld.param.u32 %r8, [blend_yuv444_param_5];
|
||||
ld.param.u32 %r11, [blend_yuv444_param_6];
|
||||
ld.param.u32 %r9, [blend_yuv444_param_7];
|
||||
ld.param.u32 %r10, [blend_yuv444_param_8];
|
||||
mov.u32 %r12, %ntid.x;
|
||||
mov.u32 %r13, %ctaid.x;
|
||||
mov.u32 %r14, %tid.x;
|
||||
mad.lo.s32 %r1, %r13, %r12, %r14;
|
||||
mov.u32 %r15, %ntid.y;
|
||||
mov.u32 %r16, %ctaid.y;
|
||||
mov.u32 %r17, %tid.y;
|
||||
mad.lo.s32 %r2, %r16, %r15, %r17;
|
||||
setp.ge.s32 %p1, %r1, %r8;
|
||||
setp.ge.s32 %p2, %r2, %r11;
|
||||
or.pred %p3, %p1, %p2;
|
||||
@%p3 bra $L__BB1_4;
|
||||
|
||||
add.s32 %r3, %r1, %r9;
|
||||
add.s32 %r4, %r2, %r10;
|
||||
or.b32 %r18, %r4, %r3;
|
||||
setp.lt.s32 %p4, %r18, 0;
|
||||
setp.ge.s32 %p5, %r3, %r6;
|
||||
or.pred %p6, %p5, %p4;
|
||||
setp.ge.s32 %p7, %r4, %r7;
|
||||
or.pred %p8, %p7, %p6;
|
||||
@%p8 bra $L__BB1_4;
|
||||
|
||||
mad.lo.s32 %r19, %r2, %r8, %r1;
|
||||
mul.wide.s32 %rd4, %r19, 4;
|
||||
cvta.to.global.u64 %rd5, %rd3;
|
||||
add.s64 %rd1, %rd5, %rd4;
|
||||
ld.global.u8 %rs1, [%rd1+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB1_4;
|
||||
|
||||
cvt.u32.u16 %r20, %rs1;
|
||||
ld.global.u8 %rs2, [%rd1];
|
||||
cvt.rn.f32.u16 %f1, %rs2;
|
||||
ld.global.u8 %rs3, [%rd1+1];
|
||||
cvt.rn.f32.u16 %f2, %rs3;
|
||||
ld.global.u8 %rs4, [%rd1+2];
|
||||
cvt.rn.f32.u16 %f3, %rs4;
|
||||
fma.rn.f32 %f4, %f1, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f5, %f2, 0f3F1D3C36, %f4;
|
||||
fma.rn.f32 %f6, %f3, 0f3D7DF3B6, %f5;
|
||||
add.f32 %f7, %f6, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r21, %f7;
|
||||
fma.rn.f32 %f8, %f1, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f9, %f2, 0fBEAD5CFB, %f8;
|
||||
fma.rn.f32 %f10, %f3, 0f3EE0DED3, %f9;
|
||||
add.f32 %f11, %f10, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r22, %f11;
|
||||
fma.rn.f32 %f12, %f1, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f13, %f2, 0fBECC3C9F, %f12;
|
||||
fma.rn.f32 %f14, %f3, 0fBD25119D, %f13;
|
||||
add.f32 %f15, %f14, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r23, %f15;
|
||||
mul.wide.s32 %rd6, %r4, %r5;
|
||||
cvt.s64.s32 %rd7, %r3;
|
||||
add.s64 %rd8, %rd6, %rd7;
|
||||
cvta.to.global.u64 %rd9, %rd2;
|
||||
add.s64 %rd10, %rd9, %rd8;
|
||||
ld.global.u8 %r24, [%rd10];
|
||||
mul.lo.s32 %r25, %r21, %r20;
|
||||
xor.b32 %r26, %r20, 255;
|
||||
mad.lo.s32 %r27, %r26, %r24, %r25;
|
||||
mul.hi.s32 %r28, %r27, -2139062143;
|
||||
add.s32 %r29, %r28, %r27;
|
||||
shr.u32 %r30, %r29, 31;
|
||||
shr.u32 %r31, %r29, 7;
|
||||
add.s32 %r32, %r31, %r30;
|
||||
st.global.u8 [%rd10], %r32;
|
||||
mul.wide.s32 %rd11, %r7, %r5;
|
||||
add.s64 %rd12, %rd10, %rd11;
|
||||
ld.global.u8 %r33, [%rd12];
|
||||
mul.lo.s32 %r34, %r22, %r20;
|
||||
mad.lo.s32 %r35, %r26, %r33, %r34;
|
||||
mul.hi.s32 %r36, %r35, -2139062143;
|
||||
add.s32 %r37, %r36, %r35;
|
||||
shr.u32 %r38, %r37, 31;
|
||||
shr.u32 %r39, %r37, 7;
|
||||
add.s32 %r40, %r39, %r38;
|
||||
st.global.u8 [%rd12], %r40;
|
||||
add.s64 %rd13, %rd12, %rd11;
|
||||
ld.global.u8 %r41, [%rd13];
|
||||
mul.lo.s32 %r42, %r23, %r20;
|
||||
mad.lo.s32 %r43, %r26, %r41, %r42;
|
||||
mul.hi.s32 %r44, %r43, -2139062143;
|
||||
add.s32 %r45, %r44, %r43;
|
||||
shr.u32 %r46, %r45, 31;
|
||||
shr.u32 %r47, %r45, 7;
|
||||
add.s32 %r48, %r47, %r46;
|
||||
st.global.u8 [%rd13], %r48;
|
||||
|
||||
$L__BB1_4:
|
||||
ret;
|
||||
|
||||
}
|
||||
// .globl blend_nv12
|
||||
.visible .entry blend_nv12(
|
||||
.param .u64 blend_nv12_param_0,
|
||||
.param .u32 blend_nv12_param_1,
|
||||
.param .u64 blend_nv12_param_2,
|
||||
.param .u32 blend_nv12_param_3,
|
||||
.param .u32 blend_nv12_param_4,
|
||||
.param .u32 blend_nv12_param_5,
|
||||
.param .u64 blend_nv12_param_6,
|
||||
.param .u32 blend_nv12_param_7,
|
||||
.param .u32 blend_nv12_param_8,
|
||||
.param .u32 blend_nv12_param_9,
|
||||
.param .u32 blend_nv12_param_10
|
||||
)
|
||||
{
|
||||
.reg .pred %p<43>;
|
||||
.reg .b16 %rs<17>;
|
||||
.reg .f32 %f<108>;
|
||||
.reg .b32 %r<123>;
|
||||
.reg .b64 %rd<35>;
|
||||
|
||||
|
||||
ld.param.u64 %rd11, [blend_nv12_param_0];
|
||||
ld.param.u32 %r21, [blend_nv12_param_1];
|
||||
ld.param.u64 %rd10, [blend_nv12_param_2];
|
||||
ld.param.u32 %r22, [blend_nv12_param_3];
|
||||
ld.param.u32 %r23, [blend_nv12_param_4];
|
||||
ld.param.u32 %r24, [blend_nv12_param_5];
|
||||
ld.param.u64 %rd12, [blend_nv12_param_6];
|
||||
ld.param.u32 %r25, [blend_nv12_param_7];
|
||||
ld.param.u32 %r26, [blend_nv12_param_8];
|
||||
ld.param.u32 %r27, [blend_nv12_param_9];
|
||||
ld.param.u32 %r28, [blend_nv12_param_10];
|
||||
cvta.to.global.u64 %rd1, %rd11;
|
||||
cvta.to.global.u64 %rd2, %rd12;
|
||||
mov.u32 %r29, %ntid.x;
|
||||
mov.u32 %r30, %ctaid.x;
|
||||
mov.u32 %r31, %tid.x;
|
||||
mad.lo.s32 %r32, %r30, %r29, %r31;
|
||||
mov.u32 %r33, %ntid.y;
|
||||
mov.u32 %r34, %ctaid.y;
|
||||
mov.u32 %r35, %tid.y;
|
||||
mad.lo.s32 %r36, %r34, %r33, %r35;
|
||||
shl.b32 %r1, %r32, 1;
|
||||
shl.b32 %r2, %r36, 1;
|
||||
setp.ge.s32 %p1, %r1, %r25;
|
||||
setp.ge.s32 %p2, %r2, %r26;
|
||||
or.pred %p3, %p1, %p2;
|
||||
mov.f32 %f102, 0f00000000;
|
||||
mov.f32 %f103, 0f00000000;
|
||||
mov.f32 %f104, 0f00000000;
|
||||
@%p3 bra $L__BB2_19;
|
||||
|
||||
cvt.s64.s32 %rd3, %r21;
|
||||
add.s32 %r3, %r2, %r28;
|
||||
setp.ge.s32 %p4, %r3, %r24;
|
||||
mul.lo.s32 %r4, %r2, %r25;
|
||||
mul.wide.s32 %rd4, %r3, %r21;
|
||||
add.s32 %r5, %r1, %r27;
|
||||
or.b32 %r38, %r5, %r3;
|
||||
setp.lt.s32 %p5, %r38, 0;
|
||||
mov.u32 %r121, 0;
|
||||
setp.ge.s32 %p6, %r5, %r23;
|
||||
or.pred %p7, %p6, %p5;
|
||||
or.pred %p8, %p4, %p7;
|
||||
@%p8 bra $L__BB2_4;
|
||||
|
||||
add.s32 %r40, %r1, %r4;
|
||||
mul.wide.s32 %rd13, %r40, 4;
|
||||
add.s64 %rd5, %rd2, %rd13;
|
||||
ld.global.u8 %rs1, [%rd5+3];
|
||||
setp.eq.s16 %p9, %rs1, 0;
|
||||
@%p9 bra $L__BB2_4;
|
||||
|
||||
cvt.u32.u16 %r42, %rs1;
|
||||
ld.global.u8 %rs5, [%rd5];
|
||||
cvt.rn.f32.u16 %f31, %rs5;
|
||||
ld.global.u8 %rs6, [%rd5+1];
|
||||
cvt.rn.f32.u16 %f32, %rs6;
|
||||
ld.global.u8 %rs7, [%rd5+2];
|
||||
cvt.rn.f32.u16 %f33, %rs7;
|
||||
fma.rn.f32 %f34, %f31, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f35, %f32, 0f3F1D3C36, %f34;
|
||||
fma.rn.f32 %f36, %f33, 0f3D7DF3B6, %f35;
|
||||
add.f32 %f37, %f36, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r43, %f37;
|
||||
cvt.s64.s32 %rd14, %r5;
|
||||
add.s64 %rd15, %rd4, %rd14;
|
||||
add.s64 %rd16, %rd1, %rd15;
|
||||
ld.global.u8 %r44, [%rd16];
|
||||
mul.lo.s32 %r45, %r43, %r42;
|
||||
xor.b32 %r46, %r42, 255;
|
||||
mad.lo.s32 %r47, %r46, %r44, %r45;
|
||||
mul.hi.s32 %r48, %r47, -2139062143;
|
||||
add.s32 %r49, %r48, %r47;
|
||||
shr.u32 %r50, %r49, 31;
|
||||
shr.u32 %r51, %r49, 7;
|
||||
add.s32 %r52, %r51, %r50;
|
||||
st.global.u8 [%rd16], %r52;
|
||||
fma.rn.f32 %f38, %f31, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f39, %f32, 0fBEAD5CFB, %f38;
|
||||
fma.rn.f32 %f40, %f33, 0f3EE0DED3, %f39;
|
||||
cvt.rn.f32.u16 %f104, %rs1;
|
||||
fma.rn.f32 %f102, %f40, %f104, 0f00000000;
|
||||
fma.rn.f32 %f41, %f31, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f42, %f32, 0fBECC3C9F, %f41;
|
||||
fma.rn.f32 %f43, %f33, 0fBD25119D, %f42;
|
||||
fma.rn.f32 %f103, %f43, %f104, 0f00000000;
|
||||
mov.u32 %r121, 1;
|
||||
|
||||
$L__BB2_4:
|
||||
add.s32 %r7, %r1, 1;
|
||||
setp.ge.s32 %p10, %r7, %r25;
|
||||
@%p10 bra $L__BB2_8;
|
||||
|
||||
add.s32 %r8, %r7, %r27;
|
||||
or.b32 %r53, %r8, %r3;
|
||||
setp.lt.s32 %p12, %r53, 0;
|
||||
setp.ge.s32 %p13, %r8, %r23;
|
||||
or.pred %p14, %p13, %p12;
|
||||
or.pred %p15, %p4, %p14;
|
||||
@%p15 bra $L__BB2_8;
|
||||
|
||||
add.s32 %r54, %r7, %r4;
|
||||
mul.wide.s32 %rd17, %r54, 4;
|
||||
add.s64 %rd6, %rd2, %rd17;
|
||||
ld.global.u8 %rs2, [%rd6+3];
|
||||
setp.eq.s16 %p16, %rs2, 0;
|
||||
@%p16 bra $L__BB2_8;
|
||||
|
||||
cvt.u32.u16 %r55, %rs2;
|
||||
ld.global.u8 %rs8, [%rd6];
|
||||
cvt.rn.f32.u16 %f44, %rs8;
|
||||
ld.global.u8 %rs9, [%rd6+1];
|
||||
cvt.rn.f32.u16 %f45, %rs9;
|
||||
ld.global.u8 %rs10, [%rd6+2];
|
||||
cvt.rn.f32.u16 %f46, %rs10;
|
||||
fma.rn.f32 %f47, %f44, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f48, %f45, 0f3F1D3C36, %f47;
|
||||
fma.rn.f32 %f49, %f46, 0f3D7DF3B6, %f48;
|
||||
add.f32 %f50, %f49, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r56, %f50;
|
||||
cvt.s64.s32 %rd18, %r8;
|
||||
add.s64 %rd19, %rd4, %rd18;
|
||||
add.s64 %rd20, %rd1, %rd19;
|
||||
ld.global.u8 %r57, [%rd20];
|
||||
mul.lo.s32 %r58, %r56, %r55;
|
||||
xor.b32 %r59, %r55, 255;
|
||||
mad.lo.s32 %r60, %r59, %r57, %r58;
|
||||
mul.hi.s32 %r61, %r60, -2139062143;
|
||||
add.s32 %r62, %r61, %r60;
|
||||
shr.u32 %r63, %r62, 31;
|
||||
shr.u32 %r64, %r62, 7;
|
||||
add.s32 %r65, %r64, %r63;
|
||||
st.global.u8 [%rd20], %r65;
|
||||
fma.rn.f32 %f51, %f44, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f52, %f45, 0fBEAD5CFB, %f51;
|
||||
fma.rn.f32 %f53, %f46, 0f3EE0DED3, %f52;
|
||||
cvt.rn.f32.u16 %f54, %rs2;
|
||||
fma.rn.f32 %f102, %f53, %f54, %f102;
|
||||
fma.rn.f32 %f55, %f44, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f56, %f45, 0fBECC3C9F, %f55;
|
||||
fma.rn.f32 %f57, %f46, 0fBD25119D, %f56;
|
||||
fma.rn.f32 %f103, %f57, %f54, %f103;
|
||||
add.f32 %f104, %f104, %f54;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_8:
|
||||
add.s32 %r11, %r2, 1;
|
||||
setp.ge.s32 %p17, %r11, %r26;
|
||||
add.s32 %r12, %r11, %r28;
|
||||
add.s32 %r13, %r4, %r25;
|
||||
cvt.s64.s32 %rd21, %r12;
|
||||
mul.lo.s64 %rd7, %rd21, %rd3;
|
||||
@%p17 bra $L__BB2_12;
|
||||
|
||||
setp.ge.s32 %p18, %r12, %r24;
|
||||
or.b32 %r66, %r5, %r12;
|
||||
setp.lt.s32 %p19, %r66, 0;
|
||||
or.pred %p21, %p6, %p19;
|
||||
or.pred %p22, %p18, %p21;
|
||||
@%p22 bra $L__BB2_12;
|
||||
|
||||
add.s32 %r67, %r1, %r13;
|
||||
mul.wide.s32 %rd22, %r67, 4;
|
||||
add.s64 %rd8, %rd2, %rd22;
|
||||
ld.global.u8 %rs3, [%rd8+3];
|
||||
setp.eq.s16 %p23, %rs3, 0;
|
||||
@%p23 bra $L__BB2_12;
|
||||
|
||||
cvt.u32.u16 %r68, %rs3;
|
||||
ld.global.u8 %rs11, [%rd8];
|
||||
cvt.rn.f32.u16 %f58, %rs11;
|
||||
ld.global.u8 %rs12, [%rd8+1];
|
||||
cvt.rn.f32.u16 %f59, %rs12;
|
||||
ld.global.u8 %rs13, [%rd8+2];
|
||||
cvt.rn.f32.u16 %f60, %rs13;
|
||||
fma.rn.f32 %f61, %f58, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f62, %f59, 0f3F1D3C36, %f61;
|
||||
fma.rn.f32 %f63, %f60, 0f3D7DF3B6, %f62;
|
||||
add.f32 %f64, %f63, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r69, %f64;
|
||||
cvt.s64.s32 %rd23, %r5;
|
||||
add.s64 %rd24, %rd7, %rd23;
|
||||
add.s64 %rd25, %rd1, %rd24;
|
||||
ld.global.u8 %r70, [%rd25];
|
||||
mul.lo.s32 %r71, %r69, %r68;
|
||||
xor.b32 %r72, %r68, 255;
|
||||
mad.lo.s32 %r73, %r72, %r70, %r71;
|
||||
mul.hi.s32 %r74, %r73, -2139062143;
|
||||
add.s32 %r75, %r74, %r73;
|
||||
shr.u32 %r76, %r75, 31;
|
||||
shr.u32 %r77, %r75, 7;
|
||||
add.s32 %r78, %r77, %r76;
|
||||
st.global.u8 [%rd25], %r78;
|
||||
fma.rn.f32 %f65, %f58, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f66, %f59, 0fBEAD5CFB, %f65;
|
||||
fma.rn.f32 %f67, %f60, 0f3EE0DED3, %f66;
|
||||
cvt.rn.f32.u16 %f68, %rs3;
|
||||
fma.rn.f32 %f102, %f67, %f68, %f102;
|
||||
fma.rn.f32 %f69, %f58, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f70, %f59, 0fBECC3C9F, %f69;
|
||||
fma.rn.f32 %f71, %f60, 0fBD25119D, %f70;
|
||||
fma.rn.f32 %f103, %f71, %f68, %f103;
|
||||
add.f32 %f104, %f104, %f68;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_12:
|
||||
or.pred %p26, %p17, %p10;
|
||||
@%p26 bra $L__BB2_16;
|
||||
|
||||
setp.ge.s32 %p27, %r12, %r24;
|
||||
add.s32 %r16, %r7, %r27;
|
||||
or.b32 %r79, %r16, %r12;
|
||||
setp.lt.s32 %p28, %r79, 0;
|
||||
setp.ge.s32 %p29, %r16, %r23;
|
||||
or.pred %p30, %p29, %p28;
|
||||
or.pred %p31, %p27, %p30;
|
||||
@%p31 bra $L__BB2_16;
|
||||
|
||||
add.s32 %r80, %r7, %r13;
|
||||
mul.wide.s32 %rd26, %r80, 4;
|
||||
add.s64 %rd9, %rd2, %rd26;
|
||||
ld.global.u8 %rs4, [%rd9+3];
|
||||
setp.eq.s16 %p32, %rs4, 0;
|
||||
@%p32 bra $L__BB2_16;
|
||||
|
||||
cvt.u32.u16 %r81, %rs4;
|
||||
ld.global.u8 %rs14, [%rd9];
|
||||
cvt.rn.f32.u16 %f72, %rs14;
|
||||
ld.global.u8 %rs15, [%rd9+1];
|
||||
cvt.rn.f32.u16 %f73, %rs15;
|
||||
ld.global.u8 %rs16, [%rd9+2];
|
||||
cvt.rn.f32.u16 %f74, %rs16;
|
||||
fma.rn.f32 %f75, %f72, 0f3E3AFB7F, 0f41800000;
|
||||
fma.rn.f32 %f76, %f73, 0f3F1D3C36, %f75;
|
||||
fma.rn.f32 %f77, %f74, 0f3D7DF3B6, %f76;
|
||||
add.f32 %f78, %f77, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r82, %f78;
|
||||
cvt.s64.s32 %rd27, %r16;
|
||||
add.s64 %rd28, %rd7, %rd27;
|
||||
add.s64 %rd29, %rd1, %rd28;
|
||||
ld.global.u8 %r83, [%rd29];
|
||||
mul.lo.s32 %r84, %r82, %r81;
|
||||
xor.b32 %r85, %r81, 255;
|
||||
mad.lo.s32 %r86, %r85, %r83, %r84;
|
||||
mul.hi.s32 %r87, %r86, -2139062143;
|
||||
add.s32 %r88, %r87, %r86;
|
||||
shr.u32 %r89, %r88, 31;
|
||||
shr.u32 %r90, %r88, 7;
|
||||
add.s32 %r91, %r90, %r89;
|
||||
st.global.u8 [%rd29], %r91;
|
||||
fma.rn.f32 %f79, %f72, 0fBDCE075F, 0f43000000;
|
||||
fma.rn.f32 %f80, %f73, 0fBEAD5CFB, %f79;
|
||||
fma.rn.f32 %f81, %f74, 0f3EE0DED3, %f80;
|
||||
cvt.rn.f32.u16 %f82, %rs4;
|
||||
fma.rn.f32 %f102, %f81, %f82, %f102;
|
||||
fma.rn.f32 %f83, %f72, 0f3EE0DED3, 0f43000000;
|
||||
fma.rn.f32 %f84, %f73, 0fBECC3C9F, %f83;
|
||||
fma.rn.f32 %f85, %f74, 0fBD25119D, %f84;
|
||||
fma.rn.f32 %f103, %f85, %f82, %f103;
|
||||
add.f32 %f104, %f104, %f82;
|
||||
add.s32 %r121, %r121, 1;
|
||||
|
||||
$L__BB2_16:
|
||||
setp.eq.s32 %p33, %r121, 0;
|
||||
setp.le.f32 %p34, %f104, 0f00000000;
|
||||
or.pred %p35, %p34, %p33;
|
||||
@%p35 bra $L__BB2_19;
|
||||
|
||||
shr.u32 %r92, %r5, 31;
|
||||
add.s32 %r93, %r5, %r92;
|
||||
shr.s32 %r19, %r93, 1;
|
||||
setp.lt.s32 %p36, %r3, -1;
|
||||
setp.lt.s32 %p37, %r5, -1;
|
||||
or.pred %p38, %p37, %p36;
|
||||
and.b32 %r94, %r93, -2;
|
||||
setp.ge.s32 %p39, %r94, %r23;
|
||||
or.pred %p40, %p38, %p39;
|
||||
shr.u32 %r95, %r3, 31;
|
||||
add.s32 %r96, %r3, %r95;
|
||||
shr.s32 %r20, %r96, 1;
|
||||
and.b32 %r97, %r96, -2;
|
||||
setp.ge.s32 %p41, %r97, %r24;
|
||||
or.pred %p42, %p40, %p41;
|
||||
@%p42 bra $L__BB2_19;
|
||||
|
||||
div.rn.f32 %f86, %f102, %f104;
|
||||
add.f32 %f87, %f86, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r98, %f87;
|
||||
div.rn.f32 %f88, %f103, %f104;
|
||||
add.f32 %f89, %f88, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r99, %f89;
|
||||
cvt.rn.f32.s32 %f90, %r121;
|
||||
div.rn.f32 %f91, %f104, %f90;
|
||||
add.f32 %f92, %f91, 0f3F000000;
|
||||
cvt.rzi.s32.f32 %r100, %f92;
|
||||
mul.wide.s32 %rd30, %r20, %r22;
|
||||
mul.wide.s32 %rd31, %r19, 2;
|
||||
add.s64 %rd32, %rd30, %rd31;
|
||||
cvta.to.global.u64 %rd33, %rd10;
|
||||
add.s64 %rd34, %rd33, %rd32;
|
||||
ld.global.u8 %r101, [%rd34];
|
||||
mul.lo.s32 %r102, %r100, %r98;
|
||||
mov.u32 %r103, 255;
|
||||
sub.s32 %r104, %r103, %r100;
|
||||
mad.lo.s32 %r105, %r104, %r101, %r102;
|
||||
mul.hi.s32 %r106, %r105, -2139062143;
|
||||
add.s32 %r107, %r106, %r105;
|
||||
shr.u32 %r108, %r107, 31;
|
||||
shr.u32 %r109, %r107, 7;
|
||||
add.s32 %r110, %r109, %r108;
|
||||
st.global.u8 [%rd34], %r110;
|
||||
ld.global.u8 %r111, [%rd34+1];
|
||||
mul.lo.s32 %r112, %r100, %r99;
|
||||
mad.lo.s32 %r113, %r104, %r111, %r112;
|
||||
mul.hi.s32 %r114, %r113, -2139062143;
|
||||
add.s32 %r115, %r114, %r113;
|
||||
shr.u32 %r116, %r115, 31;
|
||||
shr.u32 %r117, %r115, 7;
|
||||
add.s32 %r118, %r117, %r116;
|
||||
st.global.u8 [%rd34+1], %r118;
|
||||
|
||||
$L__BB2_19:
|
||||
ret;
|
||||
|
||||
}
|
||||
|
||||
@@ -66,11 +66,6 @@ struct CudaHw {
|
||||
|
||||
impl CudaHw {
|
||||
/// Build a CUDA hwdevice wrapping `cu_ctx` and a frames pool (`sw_format` = `pixel`).
|
||||
///
|
||||
/// The `bail!`s below format raw AVERROR ints eagerly BY DESIGN — do not convert them to
|
||||
/// typed errors: `open_nvenc_probed`'s bitrate ladder steps down on a typed EINVAL
|
||||
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
|
||||
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
|
||||
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
|
||||
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
|
||||
if device_ref.is_null() {
|
||||
@@ -185,6 +180,7 @@ 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,
|
||||
@@ -417,6 +413,57 @@ 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
|
||||
@@ -444,29 +491,15 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// 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
|
||||
// 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
|
||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||
// 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.
|
||||
// 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() {
|
||||
// The operator arm gains the codec gate the auto arm always had (Phase 8): split
|
||||
// "is not applicable to H264" per nvEncodeAPI.h, and h264_nvenc has no such AVOption
|
||||
// — setting it would fail the open on a leftover dict entry.
|
||||
Some(mode) if matches!(codec, Codec::H265 | Codec::Av1) => {
|
||||
opts.set("split_encode_mode", mode)
|
||||
}
|
||||
Some(_) => tracing::warn!(
|
||||
codec = codec.nvenc_name(),
|
||||
"PUNKTFUNK_SPLIT_ENCODE ignored — split encoding is not applicable to H.264 \
|
||||
(nvEncodeAPI.h)"
|
||||
),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
@@ -483,15 +516,7 @@ impl NvencEncoder {
|
||||
// sessions) and reopen this session without intra-refresh; any other failure — and
|
||||
// any failure when IR wasn't requested — propagates untouched (the bitrate probe
|
||||
// keys on EINVAL, which must not trip the latch).
|
||||
Err(e)
|
||||
if intra_refresh
|
||||
&& matches!(
|
||||
e,
|
||||
ffmpeg::Error::Other {
|
||||
errno: ffmpeg::util::error::ENOSYS
|
||||
}
|
||||
) =>
|
||||
{
|
||||
Err(e) if intra_refresh && format!("{e:#}").contains("Function not implemented") => {
|
||||
tracing::warn!(
|
||||
encoder = name,
|
||||
"NVENC intra-refresh not supported by this GPU — falling back to IDR-only \
|
||||
@@ -524,84 +549,6 @@ 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 {
|
||||
@@ -614,6 +561,7 @@ impl NvencEncoder {
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -643,9 +591,6 @@ 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,
|
||||
@@ -750,10 +695,8 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 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.
|
||||
// 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.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
let frame = self
|
||||
.frame
|
||||
@@ -762,13 +705,10 @@ 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`, 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.
|
||||
// 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.
|
||||
unsafe {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
@@ -784,7 +724,7 @@ impl NvencEncoder {
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
@@ -793,7 +733,7 @@ impl NvencEncoder {
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
@@ -802,9 +742,18 @@ 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);
|
||||
{
|
||||
// 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).
|
||||
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
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||
@@ -927,58 +876,6 @@ 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
|
||||
@@ -992,9 +889,14 @@ 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.
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
// 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(
|
||||
codec,
|
||||
PixelFormat::Bgra,
|
||||
640,
|
||||
@@ -1005,7 +907,10 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
)
|
||||
.is_ok()
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
}
|
||||
|
||||
/// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode 10-bit (HEVC Main10 /
|
||||
@@ -1021,9 +926,14 @@ 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.
|
||||
// Held until the function returns, so the level is restored after the open either way.
|
||||
let _quiet = QuietLibavLog::new();
|
||||
NvencEncoder::open(
|
||||
// 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(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
640,
|
||||
@@ -1034,7 +944,10 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.is_ok()
|
||||
.is_ok();
|
||||
// SAFETY: restore the saved global log level (scalar arg, no pointers).
|
||||
unsafe { ffi::av_log_set_level(prev) };
|
||||
ok
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,18 +28,22 @@ 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::{Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
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.
|
||||
@@ -67,179 +71,33 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
/// 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)];
|
||||
|
||||
/// (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 {
|
||||
lp_key_for(&render_node().to_string_lossy(), codec, ten_bit)
|
||||
}
|
||||
|
||||
/// [`lp_key`] with the render node explicit (device-free for the unit tests). Every part of the
|
||||
/// key is load-bearing — see [`LP_MODE`] for the two field-motivated bugs (node: cross-GPU
|
||||
/// session-killer; depth: HDR under-advertisement).
|
||||
fn lp_key_for(node: &str, codec: Codec, ten_bit: bool) -> LpKey {
|
||||
(node.to_owned(), codec.label(), ten_bit)
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
|
||||
/// only); unset → try full-feature first, fall back to low-power.
|
||||
fn low_power_override() -> Option<bool> {
|
||||
parse_low_power(&std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?)
|
||||
}
|
||||
|
||||
/// [`low_power_override`]'s value grammar (device-free for the unit tests). Anything outside the
|
||||
/// two literal sets means "no pin" — the `[full-feature, low-power]` ladder runs.
|
||||
fn parse_low_power(raw: &str) -> Option<bool> {
|
||||
match raw.trim() {
|
||||
match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entrypoint modes to attempt, in order (`false` = full-feature `EncSlice`, `true` =
|
||||
/// low-power VDEnc): an operator pin tries exactly that one; a cached resolution ([`LP_MODE`]:
|
||||
/// 1 = full-feature, 2 = low-power) skips the known-failing attempt; anything else runs the full
|
||||
/// ladder — full-feature first so AMD's first-try open stays byte-for-byte unchanged. Never
|
||||
/// empty: [`open_vaapi_encoder`] relies on at least one attempt running.
|
||||
fn entrypoint_ladder(pin: Option<bool>, cached: u8) -> &'static [bool] {
|
||||
match pin {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match cached {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`LP_MODE`] value a successful open latches (1 = full-feature, 2 = low-power). Paired with
|
||||
/// [`entrypoint_ladder`], which maps it back to the single-mode retry list.
|
||||
fn latched_mode(low_power: bool) -> u8 {
|
||||
if low_power {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// The VUI colour metadata the encoder signals for the session's depth.
|
||||
struct Vui {
|
||||
colorspace: ffi::AVColorSpace,
|
||||
range: ffi::AVColorRange,
|
||||
primaries: ffi::AVColorPrimaries,
|
||||
trc: ffi::AVColorTransferCharacteristic,
|
||||
}
|
||||
|
||||
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
|
||||
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
|
||||
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
|
||||
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
|
||||
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
|
||||
/// tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
fn vui_for(ten_bit: bool) -> Vui {
|
||||
if ten_bit {
|
||||
Vui {
|
||||
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL,
|
||||
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT2020,
|
||||
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084,
|
||||
}
|
||||
} else {
|
||||
Vui {
|
||||
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT709,
|
||||
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
|
||||
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT709,
|
||||
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit `profile` option for this codec/depth, or `None` to let the encoder derive it.
|
||||
/// HEVC Main10 is pinned explicitly so the depth is never silently dropped (`hevc_vaapi` would
|
||||
/// derive it from the P010 surfaces, but a derivation can regress quietly); 10-bit AV1 is
|
||||
/// input-driven — no profile knob — and every 8-bit open keeps the encoder default.
|
||||
fn explicit_profile(codec: Codec, ten_bit: bool) -> Option<&'static str> {
|
||||
(ten_bit && codec == Codec::H265).then_some("main10")
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 accepted verbatim; unset, junk, and out-of-range
|
||||
/// values all resolve to depth 1 — the lowest-latency structure (see the `async_depth` note at the
|
||||
/// call site for why 1 is the default and what depth ≥ 2 trades).
|
||||
fn async_depth(raw: Option<&str>) -> u32 {
|
||||
raw.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|d| (1..=8).contains(d))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The `scale_vaapi` filter args for the session's depth. The VPP's output colour is pinned to
|
||||
/// exactly what the encoder's VUI signals ([`vui_for`]) — without the explicit options the
|
||||
/// conversion matrix is whatever the driver defaults to for an unspecified output (Mesa: BT.601),
|
||||
/// a hue shift against the signaled VUI. (The PQ transfer is per-channel and rides through the
|
||||
/// matrix untouched.)
|
||||
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
||||
if ten_bit {
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited"
|
||||
} else {
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited"
|
||||
}
|
||||
}
|
||||
|
||||
/// What a (captured format, negotiated bit depth) pair resolves to at open. 10-bit rides on the
|
||||
/// captured PIXELS, not the negotiated depth — see [`crate::ten_bit_input`] for the failure the
|
||||
/// reverse shape produces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DepthResolution {
|
||||
/// PQ-graded 10-bit frames on a non-10-bit session: refuse the open, so PQ content is never
|
||||
/// mislabeled BT.709.
|
||||
RefuseMislabeledPq,
|
||||
/// 10-bit negotiated but the capture stayed SDR: honestly encode 8-bit (with a warning).
|
||||
SdrDowngrade,
|
||||
/// Format and negotiated depth agree.
|
||||
Agreed,
|
||||
}
|
||||
|
||||
/// See [`DepthResolution`].
|
||||
fn resolve_depth(format: PixelFormat, bit_depth: u8) -> DepthResolution {
|
||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||
DepthResolution::RefuseMislabeledPq
|
||||
} else if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||
DepthResolution::SdrDowngrade
|
||||
} else {
|
||||
DepthResolution::Agreed
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a codec is even eligible for the 10-bit probe: PyroWave answers 10-bit support on its
|
||||
/// own path (no VAAPI involved), and a codec without a 10-bit profile can never pass.
|
||||
fn ten_bit_probe_eligible(codec: Codec) -> bool {
|
||||
codec.supports_10bit() && codec != Codec::PyroWave
|
||||
}
|
||||
|
||||
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
|
||||
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
|
||||
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
|
||||
@@ -258,13 +116,16 @@ unsafe fn open_vaapi_encoder(
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
ten_bit: bool,
|
||||
) -> Result<encoder::video::Encoder> {
|
||||
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] = entrypoint_ladder(low_power_override(), cached);
|
||||
let idx = lp_idx(codec);
|
||||
let modes: &[bool] = match low_power_override() {
|
||||
Some(true) => &[true],
|
||||
Some(false) => &[false],
|
||||
None => match LP_MODE[idx].load(Ordering::Relaxed) {
|
||||
1 => &[false],
|
||||
2 => &[true],
|
||||
_ => &[false, true],
|
||||
},
|
||||
};
|
||||
let mut first_err = None;
|
||||
for &lp in modes {
|
||||
match open_vaapi_encoder_mode(
|
||||
@@ -279,9 +140,7 @@ unsafe fn open_vaapi_encoder(
|
||||
lp,
|
||||
) {
|
||||
Ok(enc) => {
|
||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||
m.insert(key.clone(), latched_mode(lp));
|
||||
}
|
||||
LP_MODE[idx].store(if lp { 2 } else { 1 }, Ordering::Relaxed);
|
||||
if lp {
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
@@ -337,18 +196,32 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
let vui = vui_for(ten_bit);
|
||||
(*raw).colorspace = vui.colorspace;
|
||||
(*raw).color_range = vui.range;
|
||||
(*raw).color_primaries = vui.primaries;
|
||||
(*raw).color_trc = vui.trc;
|
||||
if ten_bit {
|
||||
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
|
||||
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
|
||||
// zero-copy path). The client decoder auto-detects PQ from the VUI.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
} else {
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
|
||||
let mut opts = Dictionary::new();
|
||||
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
||||
opts.set("profile", profile);
|
||||
if ten_bit && codec == Codec::H265 {
|
||||
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
|
||||
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
|
||||
opts.set("profile", "main10");
|
||||
}
|
||||
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
|
||||
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
|
||||
@@ -358,7 +231,11 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
|
||||
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
|
||||
// see `gpuclocks` for the session clock pin that removes the ramp tax.
|
||||
let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref());
|
||||
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|d| (1..=8).contains(d))
|
||||
.unwrap_or(1);
|
||||
opts.set("async_depth", &depth.to_string());
|
||||
if low_power {
|
||||
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
|
||||
@@ -376,20 +253,20 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
unsafe {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
|
||||
// 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) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -402,7 +279,9 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,23 +291,24 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
|
||||
/// the Welcome (honest downgrade).
|
||||
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
if !ten_bit_probe_eligible(codec) {
|
||||
if !codec.supports_10bit() || codec == Codec::PyroWave {
|
||||
return false;
|
||||
}
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// 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
|
||||
// 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
|
||||
// `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 {
|
||||
match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
|
||||
// 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) {
|
||||
Ok(hw) => open_vaapi_encoder(
|
||||
codec,
|
||||
640,
|
||||
@@ -441,7 +321,9 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
ffi::av_log_set_level(prev);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,7 +819,24 @@ impl DmabufInner {
|
||||
}
|
||||
init!(src, ptr::null(), "buffer");
|
||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||
init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi");
|
||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
|
||||
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
|
||||
// the matrix untouched). Without the explicit options the conversion matrix is
|
||||
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
|
||||
// shift against the signaled VUI.
|
||||
if ten_bit {
|
||||
init!(
|
||||
scale,
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
} else {
|
||||
init!(
|
||||
scale,
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
}
|
||||
init!(sink, ptr::null(), "buffersink");
|
||||
|
||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||
@@ -1035,8 +934,7 @@ 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 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.
|
||||
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
||||
// * `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] =
|
||||
@@ -1052,17 +950,7 @@ 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();
|
||||
// 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].size = 0;
|
||||
desc.objects[0].format_modifier = dmabuf.modifier;
|
||||
desc.nb_layers = 1;
|
||||
desc.layers[0].format = self.fourcc;
|
||||
@@ -1111,18 +999,8 @@ 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 {
|
||||
let e = format!("av_buffersrc_add_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
bail!("av_buffersrc_add_frame failed ({r})");
|
||||
}
|
||||
t_push = t0.elapsed();
|
||||
let mut nv12 = ffi::av_frame_alloc();
|
||||
@@ -1132,11 +1010,8 @@ impl DmabufInner {
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
let e = format!("av_buffersink_get_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
bail!("av_buffersink_get_frame failed ({r})");
|
||||
}
|
||||
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||
t_pull = t0.elapsed() - t_push;
|
||||
(*nv12).pts = pts;
|
||||
(*nv12).pict_type = if idr {
|
||||
@@ -1229,18 +1104,21 @@ impl VaapiEncoder {
|
||||
chroma: super::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
|
||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects.
|
||||
match resolve_depth(format, bit_depth) {
|
||||
DepthResolution::RefuseMislabeledPq => bail!(
|
||||
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
|
||||
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
|
||||
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
|
||||
if format.is_hdr_rgb10() && bit_depth != 10 {
|
||||
bail!(
|
||||
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
|
||||
refusing to mislabel PQ content"
|
||||
),
|
||||
DepthResolution::SdrDowngrade => tracing::warn!(
|
||||
);
|
||||
}
|
||||
if bit_depth == 10 && !format.is_hdr_rgb10() {
|
||||
tracing::warn!(
|
||||
bit_depth,
|
||||
?format,
|
||||
"10-bit requested but the capture stayed SDR — encoding 8-bit"
|
||||
),
|
||||
DepthResolution::Agreed => {}
|
||||
);
|
||||
}
|
||||
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
|
||||
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
|
||||
@@ -1390,261 +1268,3 @@ impl Encoder for VaapiEncoder {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
|
||||
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
|
||||
/// open must stay byte-for-byte unchanged.
|
||||
#[test]
|
||||
fn entrypoint_ladder_orders_and_pins() {
|
||||
assert_eq!(entrypoint_ladder(None, 0), &[false, true]);
|
||||
assert_eq!(entrypoint_ladder(None, 1), &[false]);
|
||||
assert_eq!(entrypoint_ladder(None, 2), &[true]);
|
||||
// A corrupt/unknown cache value degrades to the full ladder, never to a wrong pin.
|
||||
assert_eq!(entrypoint_ladder(None, 77), &[false, true]);
|
||||
// The pin beats the cache in BOTH directions — what makes PUNKTFUNK_VAAPI_LOW_POWER a
|
||||
// real escape hatch from a stale latch.
|
||||
for cached in [0u8, 1, 2, 77] {
|
||||
assert_eq!(entrypoint_ladder(Some(true), cached), &[true]);
|
||||
assert_eq!(entrypoint_ladder(Some(false), cached), &[false]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The latch round-trip: what a successful open stores makes the NEXT open attempt exactly
|
||||
/// the mode that worked (the "skip the known-failing attempt and its libav error spew"
|
||||
/// contract — and, per [`LP_MODE`], the shape that must never cross devices or depths).
|
||||
#[test]
|
||||
fn latch_round_trip_pins_the_resolved_mode() {
|
||||
for lp in [false, true] {
|
||||
assert_eq!(entrypoint_ladder(None, latched_mode(lp)), &[lp]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_LOW_POWER` grammar: the two lowercase literal sets pin; anything else —
|
||||
/// including uppercase — means "no pin" (the ladder runs). Case-sensitivity is pinned as
|
||||
/// shipped behavior.
|
||||
#[test]
|
||||
fn low_power_grammar() {
|
||||
for s in ["1", "true", "yes", "on", " on ", "yes\n"] {
|
||||
assert_eq!(parse_low_power(s), Some(true), "{s:?}");
|
||||
}
|
||||
for s in ["0", "false", "no", "off", " off "] {
|
||||
assert_eq!(parse_low_power(s), Some(false), "{s:?}");
|
||||
}
|
||||
for s in ["", "2", "TRUE", "On", "enabled", "low_power"] {
|
||||
assert_eq!(parse_low_power(s), None, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every part of the LP_MODE key is load-bearing (see the [`LP_MODE`] field comments): the
|
||||
/// node (a GPU-preference switch must re-resolve — the old codec-only key was a
|
||||
/// session-killer), the codec, and the depth (an 8-bit answer must never pin the 10-bit open —
|
||||
/// the HDR under-advertisement).
|
||||
#[test]
|
||||
fn lp_key_separates_node_codec_and_depth() {
|
||||
let base = lp_key_for("/dev/dri/renderD128", Codec::H265, false);
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD129", Codec::H265, false));
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H264, false));
|
||||
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H265, true));
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 verbatim; unset, junk, zero, and past-the-cap
|
||||
/// all resolve to the lowest-latency depth 1.
|
||||
#[test]
|
||||
fn async_depth_grammar() {
|
||||
assert_eq!(async_depth(None), 1);
|
||||
assert_eq!(async_depth(Some("1")), 1);
|
||||
assert_eq!(async_depth(Some("2")), 2);
|
||||
assert_eq!(async_depth(Some("8")), 8);
|
||||
assert_eq!(async_depth(Some("0")), 1);
|
||||
assert_eq!(async_depth(Some("9")), 1);
|
||||
assert_eq!(async_depth(Some("-1")), 1);
|
||||
assert_eq!(async_depth(Some("fast")), 1);
|
||||
}
|
||||
|
||||
/// The swscale source map: packed RGB/BGR and the GNOME 50+ HDR 2:10:10:10 layouts convert;
|
||||
/// planar/video formats are refused (the CPU path is a packed-RGB fallback, not a general
|
||||
/// converter).
|
||||
#[test]
|
||||
fn sws_src_accepts_packed_rgb_only() {
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||
assert_eq!(vaapi_sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||
assert_eq!(
|
||||
vaapi_sws_src(PixelFormat::X2Rgb10).unwrap(),
|
||||
Pixel::X2RGB10LE
|
||||
);
|
||||
assert_eq!(
|
||||
vaapi_sws_src(PixelFormat::X2Bgr10).unwrap(),
|
||||
Pixel::X2BGR10LE
|
||||
);
|
||||
for f in [
|
||||
PixelFormat::Nv12,
|
||||
PixelFormat::P010,
|
||||
PixelFormat::Rgb10a2,
|
||||
PixelFormat::Yuv444,
|
||||
] {
|
||||
assert!(vaapi_sws_src(f).is_err(), "{f:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// VUI ↔ CSC agreement: the signaled VUI and the zero-copy VPP's pinned output colour must
|
||||
/// name the SAME matrix/range per depth — a mismatch is exactly the Mesa-BT.601 hue shift the
|
||||
/// pin exists to prevent.
|
||||
#[test]
|
||||
fn vui_and_scale_args_agree_per_depth() {
|
||||
let sdr = vui_for(false);
|
||||
assert!(matches!(sdr.colorspace, ffi::AVColorSpace::AVCOL_SPC_BT709));
|
||||
assert!(matches!(sdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||
assert!(matches!(
|
||||
sdr.primaries,
|
||||
ffi::AVColorPrimaries::AVCOL_PRI_BT709
|
||||
));
|
||||
assert!(matches!(
|
||||
sdr.trc,
|
||||
ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709
|
||||
));
|
||||
let args = scale_vaapi_args(false).to_str().unwrap();
|
||||
for needle in ["format=nv12", "out_color_matrix=bt709", "out_range=limited"] {
|
||||
assert!(
|
||||
args.contains(needle),
|
||||
"SDR scale args miss {needle}: {args}"
|
||||
);
|
||||
}
|
||||
|
||||
let hdr = vui_for(true);
|
||||
assert!(matches!(
|
||||
hdr.colorspace,
|
||||
ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL
|
||||
));
|
||||
assert!(matches!(hdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
|
||||
assert!(matches!(
|
||||
hdr.primaries,
|
||||
ffi::AVColorPrimaries::AVCOL_PRI_BT2020
|
||||
));
|
||||
assert!(matches!(
|
||||
hdr.trc,
|
||||
ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084
|
||||
));
|
||||
let args = scale_vaapi_args(true).to_str().unwrap();
|
||||
for needle in [
|
||||
"format=p010",
|
||||
"out_color_matrix=bt2020",
|
||||
"out_range=limited",
|
||||
] {
|
||||
assert!(
|
||||
args.contains(needle),
|
||||
"HDR scale args miss {needle}: {args}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The honest-downgrade table at open: PQ pixels demand a 10-bit session (refused otherwise —
|
||||
/// PQ content must never be mislabeled BT.709); a 10-bit session over SDR pixels downgrades
|
||||
/// honestly to 8-bit; agreement passes both ways.
|
||||
#[test]
|
||||
fn depth_resolution_table() {
|
||||
use DepthResolution::*;
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 8), RefuseMislabeledPq);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 8), RefuseMislabeledPq);
|
||||
assert_eq!(resolve_depth(PixelFormat::Bgrx, 10), SdrDowngrade);
|
||||
assert_eq!(resolve_depth(PixelFormat::Bgrx, 8), Agreed);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 10), Agreed);
|
||||
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 10), Agreed);
|
||||
}
|
||||
|
||||
/// Main10 is pinned explicitly for 10-bit HEVC ONLY: 10-bit AV1 is input-driven (no profile
|
||||
/// knob), and every 8-bit open keeps the encoder's default profile.
|
||||
#[test]
|
||||
fn explicit_profile_is_hevc_main10_only() {
|
||||
assert_eq!(explicit_profile(Codec::H265, true), Some("main10"));
|
||||
assert_eq!(explicit_profile(Codec::H265, false), None);
|
||||
assert_eq!(explicit_profile(Codec::Av1, true), None);
|
||||
assert_eq!(explicit_profile(Codec::Av1, false), None);
|
||||
assert_eq!(explicit_profile(Codec::H264, true), None);
|
||||
assert_eq!(explicit_profile(Codec::H264, false), None);
|
||||
}
|
||||
|
||||
/// The 10-bit probe gate: HEVC and AV1 are probe-eligible; H.264 (no 10-bit path here) and
|
||||
/// PyroWave (answers 10-bit on its own path, no VAAPI involved) are refused before any device
|
||||
/// is touched — this is what keeps the probe safe on GPU-less CI.
|
||||
#[test]
|
||||
fn ten_bit_probe_gate() {
|
||||
assert!(ten_bit_probe_eligible(Codec::H265));
|
||||
assert!(ten_bit_probe_eligible(Codec::Av1));
|
||||
assert!(!ten_bit_probe_eligible(Codec::H264));
|
||||
assert!(!ten_bit_probe_eligible(Codec::PyroWave));
|
||||
}
|
||||
|
||||
/// Probe smoke on real silicon: H.264 VAAPI encode opens on every supported AMD/Intel GPU;
|
||||
/// the narrower codecs are printed, not asserted (device-dependent). Build anywhere, run on a
|
||||
/// VAAPI host:
|
||||
/// cargo test -p pf-encode --no-run
|
||||
/// <host> target/debug/deps/pf_encode-<hash> --ignored --nocapture vaapi_probe_smoke
|
||||
#[test]
|
||||
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||
fn vaapi_probe_smoke() {
|
||||
assert!(
|
||||
probe_can_encode(Codec::H264),
|
||||
"H.264 VAAPI encode should open on any supported AMD/Intel GPU"
|
||||
);
|
||||
for codec in [Codec::H265, Codec::Av1] {
|
||||
eprintln!("probe_can_encode({codec:?}) = {}", probe_can_encode(codec));
|
||||
eprintln!(
|
||||
"probe_can_encode_10bit({codec:?}) = {}",
|
||||
probe_can_encode_10bit(codec)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU-path encode round-trip on real silicon: open → BGRX frames → poll AUs → the first AU
|
||||
/// is the IDR. Exercises the swscale CSC, the VA surface upload, and the entrypoint ladder
|
||||
/// end-to-end (same recipe as [`vaapi_probe_smoke`]).
|
||||
#[test]
|
||||
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||
fn vaapi_cpu_encode_smoke() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc = VaapiEncoder::open(
|
||||
Codec::H264,
|
||||
PixelFormat::Bgrx,
|
||||
w,
|
||||
h,
|
||||
30,
|
||||
2_000_000,
|
||||
8,
|
||||
crate::ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
let mut aus = Vec::new();
|
||||
for i in 0..30u32 {
|
||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||
for px in buf.chunks_exact_mut(4) {
|
||||
px.copy_from_slice(&[(i * 8) as u8, 0x40, 0xC0, 0xFF]);
|
||||
}
|
||||
let frame = CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: u64::from(i) * 33_333_333,
|
||||
format: PixelFormat::Bgrx,
|
||||
payload: FramePayload::Cpu(buf),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit(&frame).expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
assert!(!aus.is_empty(), "no AUs out of 30 submitted frames");
|
||||
assert!(aus[0].keyframe, "the first AU must be the IDR");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,15 +47,6 @@ 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;
|
||||
@@ -507,366 +498,3 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,836 +0,0 @@
|
||||
//! 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))
|
||||
}
|
||||
@@ -7,18 +7,6 @@ use anyhow::Result;
|
||||
use ash::vk;
|
||||
use pf_frame::PixelFormat;
|
||||
|
||||
/// Whether a device extension is in an enumerated properties list — the gate both Vulkan encode
|
||||
/// backends use before enabling `VK_EXT_queue_family_foreign` (Phase 8: the FOREIGN queue-family
|
||||
/// barriers were used without the extension ever being enabled; `pf-presenter/dmabuf.rs` is the
|
||||
/// in-repo precedent that enables it).
|
||||
pub(super) fn ext_advertised(exts: &[vk::ExtensionProperties], name: &std::ffi::CStr) -> bool {
|
||||
exts.iter().any(|e| {
|
||||
// SAFETY: `extension_name` is a spec-guaranteed NUL-terminated UTF-8 byte array inside
|
||||
// the driver-filled `VkExtensionProperties` (VK_MAX_EXTENSION_NAME_SIZE bound).
|
||||
unsafe { std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == name }
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
@@ -66,59 +54,6 @@ 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,
|
||||
@@ -135,21 +70,6 @@ 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(
|
||||
@@ -188,15 +108,9 @@ 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::{AsRawFd, IntoRawFd};
|
||||
use std::os::fd::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,
|
||||
@@ -241,16 +155,14 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
ci = ci.push_next(pl);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
// Unwind discipline below mirrors `make_plain_image`: every failure destroys what this call
|
||||
// created (and ONLY that — the caller's `DmabufFrame` fd stays theirs).
|
||||
// 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();
|
||||
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.as_raw_fd(),
|
||||
dup,
|
||||
&mut p,
|
||||
);
|
||||
p.memory_type_bits
|
||||
@@ -269,85 +181,25 @@ 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.as_raw_fd());
|
||||
let mem = match device.allocate_memory(
|
||||
.fd(dup);
|
||||
let mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ti)
|
||||
.push_next(&mut ded)
|
||||
.push_next(&mut import),
|
||||
None,
|
||||
) {
|
||||
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(
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = 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))
|
||||
Ok((img, mem, view))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn make_plain_image(
|
||||
@@ -407,98 +259,3 @@ pub(crate) unsafe fn make_plain_image(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn ext_advertised_matches_exact_name() {
|
||||
let mut e = ash::vk::ExtensionProperties::default();
|
||||
let name = b"VK_EXT_queue_family_foreign\0";
|
||||
for (i, b) in name.iter().enumerate() {
|
||||
e.extension_name[i] = *b as std::ffi::c_char;
|
||||
}
|
||||
let exts = [ash::vk::ExtensionProperties::default(), e];
|
||||
assert!(super::ext_advertised(
|
||||
&exts,
|
||||
ash::ext::queue_family_foreign::NAME
|
||||
));
|
||||
assert!(!super::ext_advertised(
|
||||
&exts[..1],
|
||||
ash::ext::queue_family_foreign::NAME
|
||||
));
|
||||
}
|
||||
|
||||
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,74 +80,3 @@ 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,520 +67,12 @@ 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
|
||||
}
|
||||
|
||||
/// Whether the operator EXPLICITLY forced sub-frame readback on (`PUNKTFUNK_NVENC_SUBFRAME=1`)
|
||||
/// — the log-severity input to [`resolve_split_subframe`]: a forced knob being overridden
|
||||
/// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their
|
||||
/// resolved subframe state (an env re-read at reconfigure would violate the "open and
|
||||
/// reconfigure present identical init params" invariant).
|
||||
/// Linux-cfg'd like its ONLY caller (the `nvenc_cuda` query_caps latch) — Windows sessions have
|
||||
/// `subframe == forced` by construction (env opt-in only) and never consult this; without the
|
||||
/// cfg it is dead code on every Windows leg (item-level dead_code, the recurring trap).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn subframe_env_forced() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref(),
|
||||
Ok("1")
|
||||
)
|
||||
}
|
||||
|
||||
/// The split-encode × sub-frame arbitration (Phase 8; verified against `nvEncodeAPI.h`'s own
|
||||
/// `splitEncodeMode` doc, not folklore):
|
||||
/// - **H.264**: split "is not applicable" — hard-DISABLE the mode so the written config, the
|
||||
/// ceiling-cache key, the split diagnostic log and the rejection-retry all stay truthful (the
|
||||
/// retry used to re-open a byte-identical session after an H.264 "split rejection").
|
||||
/// - **HEVC**: split is "not supported if … subframe mode" — when WE force split
|
||||
/// (TWO/THREE/AUTO_FORCED, e.g. the 4K120 throughput requirement), sub-frame yields. Under
|
||||
/// plain AUTO the driver arbitrates — the shipped fleet state (1080p–1440p240 all run
|
||||
/// AUTO+subframe); keying on `!= DISABLE` here would have disarmed the Phase-3 chunked-poll
|
||||
/// feature fleet-wide.
|
||||
/// - **AV1**: both legal (sub-frame is per-tile; split is constrained only by
|
||||
/// output-into-vidmem, which we never use) — untouched.
|
||||
///
|
||||
/// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back
|
||||
/// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave
|
||||
/// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without
|
||||
/// `reportSliceOffsets`, so neither loop exit ever fires).
|
||||
pub(super) fn resolve_split_subframe(
|
||||
codec: Codec,
|
||||
split_mode: u32,
|
||||
subframe: bool,
|
||||
subframe_forced: bool,
|
||||
) -> (u32, bool) {
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
if codec == Codec::H264 {
|
||||
return (M::NV_ENC_SPLIT_DISABLE_MODE as u32, subframe);
|
||||
}
|
||||
let split_forced = split_mode == M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
|| split_mode == M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32
|
||||
|| split_mode == M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
|
||||
if codec == Codec::H265 && split_forced && subframe {
|
||||
if subframe_forced {
|
||||
tracing::warn!(
|
||||
split_mode,
|
||||
"HEVC forced split-encode and PUNKTFUNK_NVENC_SUBFRAME=1 are mutually \
|
||||
unsupported (nvEncodeAPI.h) — sub-frame readback disabled for this session; \
|
||||
set PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
split_mode,
|
||||
"HEVC forced split-encode supersedes default-on sub-frame readback (mutually \
|
||||
unsupported per nvEncodeAPI.h; split is the 4K120 throughput lever) — set \
|
||||
PUNKTFUNK_SPLIT_ENCODE=0 to choose sub-frame instead"
|
||||
);
|
||||
}
|
||||
return (split_mode, false);
|
||||
}
|
||||
(split_mode, subframe)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod split_subframe_tests {
|
||||
use super::{resolve_split_subframe, Codec};
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
const AUTO: u32 = M::NV_ENC_SPLIT_AUTO_MODE as u32;
|
||||
const TWO: u32 = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
const AUTO_F: u32 = M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32;
|
||||
const DISABLE: u32 = M::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
/// THE FLEET CASE: plain AUTO + default-on sub-frame must pass through untouched — the
|
||||
/// driver arbitrates. Keying the rule on `!= DISABLE` would disarm sub-frame on every
|
||||
/// default Linux HEVC session (AUTO == 0 is the resolver's fallthrough).
|
||||
#[test]
|
||||
fn hevc_auto_keeps_subframe() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, AUTO, true, false),
|
||||
(AUTO, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_forced_split_drops_subframe() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, TWO, true, false),
|
||||
(TWO, false)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, AUTO_F, true, true),
|
||||
(AUTO_F, false)
|
||||
);
|
||||
// No sub-frame to drop → nothing changes.
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, TWO, false, false),
|
||||
(TWO, false)
|
||||
);
|
||||
// Explicitly disabled split → sub-frame kept (the documented escape).
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H265, DISABLE, true, true),
|
||||
(DISABLE, true)
|
||||
);
|
||||
}
|
||||
|
||||
/// H.264: split "is not applicable" (nvEncodeAPI.h) — hard-DISABLE regardless of the
|
||||
/// resolved mode; sub-frame (H.264 slices) is unaffected.
|
||||
#[test]
|
||||
fn h264_split_hard_disabled() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H264, TWO, true, false),
|
||||
(DISABLE, true)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::H264, AUTO, false, false),
|
||||
(DISABLE, false)
|
||||
);
|
||||
}
|
||||
|
||||
/// AV1: both features are legal together (per-tile sub-frame; split constrained only by
|
||||
/// output-into-vidmem) — the arbitration must not touch it.
|
||||
#[test]
|
||||
fn av1_untouched() {
|
||||
assert_eq!(
|
||||
resolve_split_subframe(Codec::Av1, TWO, true, true),
|
||||
(TWO, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [`plan_range_recovery`] checks against
|
||||
/// (`next_ts - RFI_DPB` = the oldest frame still in the DPB).
|
||||
/// 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).
|
||||
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
|
||||
@@ -727,27 +219,7 @@ 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.
|
||||
// `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 {
|
||||
if c.chroma_444 && c.full_chroma_input {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
if c.bit_depth == 10 {
|
||||
|
||||
@@ -8,77 +8,27 @@
|
||||
//! 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 {
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_VERSION => {
|
||||
invalid_version(SESSION_OPENED.load(Ordering::Relaxed))
|
||||
}
|
||||
// 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_NO_ENCODE_DEVICE => {
|
||||
"this GPU exposes no usable NVENC engine — it has no hardware video encoder, or NVENC is \
|
||||
disabled on this card"
|
||||
@@ -122,144 +72,9 @@ 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 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.
|
||||
/// (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?)".
|
||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
||||
}
|
||||
|
||||
@@ -246,17 +246,6 @@ 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]
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
//! 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,24 +51,6 @@ 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,
|
||||
@@ -77,16 +59,7 @@ impl OpenH264Encoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
) -> Result<Self> {
|
||||
// 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"
|
||||
);
|
||||
// validate_dimensions() ran in open_video: even, non-zero, <= 4096.
|
||||
let bps: u32 = bitrate_bps.try_into().unwrap_or(u32::MAX);
|
||||
let cfg = EncoderConfig::new()
|
||||
.usage_type(UsageType::ScreenContentRealTime)
|
||||
@@ -363,40 +336,4 @@ 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,11 +66,425 @@ use windows::Win32::Storage::FileSystem::{
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
|
||||
// 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;
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
use sys::{result_name, AmfVariant};
|
||||
|
||||
@@ -858,11 +1272,7 @@ impl AmfEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
|
||||
}
|
||||
// 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);
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// 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
|
||||
@@ -1959,27 +2369,31 @@ impl Encoder for AmfEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// 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 {
|
||||
// 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) {
|
||||
*marked = None;
|
||||
}
|
||||
}
|
||||
match plan.anchor {
|
||||
// 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 {
|
||||
Some((slot, ltr_frame)) => {
|
||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||
self.pending_force = Some(slot);
|
||||
@@ -2008,12 +2422,13 @@ 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.
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
|
||||
// no such property (and no HDR sessions negotiate H.264).
|
||||
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
|
||||
// Permanent: VCN hardware does not encode 4:4:4.
|
||||
chroma_444: false,
|
||||
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver
|
||||
@@ -2345,10 +2760,6 @@ 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;
|
||||
@@ -2367,8 +2778,6 @@ 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,
|
||||
@@ -2712,6 +3121,10 @@ mod tests {
|
||||
}
|
||||
};
|
||||
enc.set_hdr_meta(Some(sample_hdr_meta()));
|
||||
assert!(
|
||||
enc.caps().supports_hdr_metadata,
|
||||
"HEVC 10-bit reports HDR SEI capability"
|
||||
);
|
||||
let mut aus: Vec<EncodedFrame> = Vec::new();
|
||||
for i in 0..6 {
|
||||
let frame = CapturedFrame {
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
//! 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,13 +95,6 @@ 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,
|
||||
}
|
||||
@@ -129,56 +122,9 @@ impl WinVendor {
|
||||
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
|
||||
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
|
||||
fn zerocopy_enabled(vendor: WinVendor) -> bool {
|
||||
zerocopy_active(pf_host_config::config().zerocopy, vendor)
|
||||
}
|
||||
|
||||
/// The pure half of [`zerocopy_enabled`]: an operator override wins; unset resolves to the
|
||||
/// per-vendor default (AMF on, QSV off — see the validation status above).
|
||||
fn zerocopy_active(override_: Option<bool>, vendor: WinVendor) -> bool {
|
||||
override_.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(|| {
|
||||
parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
/// The pure half of [`poll_spin_cap_us`]: parse, clamp to [`MAX_POLL_SPIN_MS`] BEFORE the µs
|
||||
/// conversion (the ordering the doc above proves is load-bearing), and default to 0 — no spin,
|
||||
/// the libavcodec AMF buffer can't be spun out.
|
||||
fn parse_poll_spin_cap_us(raw: Option<&str>) -> u64 {
|
||||
raw.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
|
||||
.unwrap_or(0)
|
||||
pf_host_config::config()
|
||||
.zerocopy
|
||||
.unwrap_or(matches!(vendor, WinVendor::Amf))
|
||||
}
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||
@@ -205,94 +151,8 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
}
|
||||
|
||||
/// Does this captured format imply a 10-bit encode (P010 / 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)
|
||||
}
|
||||
|
||||
/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
|
||||
/// routing DECISION, split from the D3D11 copies so it is testable.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ReadbackRoute {
|
||||
/// Same-format `CopyResource` + plane-by-plane copy (NV12/P010 from the video processor).
|
||||
Yuv,
|
||||
/// BGRA staging + swscale BGRA→NV12 — the 8-bit fallback when the capturer's video
|
||||
/// processor latched off.
|
||||
Bgra,
|
||||
/// R10G10B10A2 staging + swscale X2BGR10→P010 — the HDR twin of that fallback.
|
||||
Rgb10,
|
||||
}
|
||||
|
||||
/// Route a captured format, guarding the mid-stream depth change first: the predicate matches
|
||||
/// what the encoder was built from (`ten_bit_input`), so the guard can only fire on a GENUINE
|
||||
/// depth change under the encoder — never, as it used to, on every frame of a session that
|
||||
/// merely negotiated 10-bit over an 8-bit capture (see [`is_10bit_format`]).
|
||||
fn readback_route(format: PixelFormat, ten_bit: bool) -> Result<ReadbackRoute> {
|
||||
anyhow::ensure!(
|
||||
is_10bit_format(format) == ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
if ten_bit { 10 } else { 8 }
|
||||
);
|
||||
Ok(match format {
|
||||
PixelFormat::Nv12 | PixelFormat::P010 => ReadbackRoute::Yuv,
|
||||
PixelFormat::Bgra | PixelFormat::Bgrx => ReadbackRoute::Bgra,
|
||||
PixelFormat::Rgb10a2 => ReadbackRoute::Rgb10,
|
||||
other => {
|
||||
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The vendor-specific low-latency option set for [`open_win_encoder`], pure so the latency
|
||||
/// contract is pinned by tests. Unknown private options are ignored by `avcodec_open2` (left in
|
||||
/// the dict), so vendor/codec-specific keys are safe to set unconditionally.
|
||||
fn vendor_opts(vendor: WinVendor, amf_usage: &str) -> Vec<(&'static str, String)> {
|
||||
match vendor {
|
||||
WinVendor::Amf => vec![
|
||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies
|
||||
// by VCN generation/driver — measured on-box rather than assumed.
|
||||
("usage", amf_usage.to_owned()),
|
||||
("rc", "cbr".into()),
|
||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||
// low-latency preset choice on the NVENC path).
|
||||
("quality", "speed".into()),
|
||||
("preanalysis", "false".into()),
|
||||
("enforce_hrd", "true".into()),
|
||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||
("latency", "true".into()),
|
||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||
("bf", "0".into()),
|
||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||
("header_insertion_mode", "idr".into()),
|
||||
],
|
||||
WinVendor::Qsv => vec![
|
||||
("preset", "veryfast".into()),
|
||||
("async_depth", "1".into()), // bound in-flight frames — the big QSV latency lever
|
||||
("low_power", "1".into()), // VDEnc fixed-function path (lower latency)
|
||||
("look_ahead", "0".into()), // (h264_qsv only; ignored on hevc/av1)
|
||||
("forced_idr", "1".into()), // a forced key frame becomes a real IDR
|
||||
("scenario", "displayremoting".into()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind flags on the FFmpeg-allocated zero-copy pool. AMF reads it as encoder input
|
||||
/// (RENDER_TARGET + SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx
|
||||
/// surface (DECODER | VIDEO_ENCODER). The `CopySubresourceRegion` into the pool works with any
|
||||
/// usable DEFAULT-usage texture regardless.
|
||||
fn pool_bind_flags(vendor: WinVendor) -> u32 {
|
||||
match vendor {
|
||||
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||
}
|
||||
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
|
||||
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||
@@ -355,11 +215,40 @@ unsafe fn open_win_encoder(
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
}
|
||||
|
||||
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
|
||||
// Low-latency tuning. Unknown private options are ignored by avcodec_open2 (left in the dict),
|
||||
// so vendor-specific keys are safe to set unconditionally.
|
||||
let mut opts = Dictionary::new();
|
||||
let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||
for (k, v) in vendor_opts(vendor, &usage) {
|
||||
opts.set(k, &v);
|
||||
match vendor {
|
||||
WinVendor::Amf => {
|
||||
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
|
||||
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
|
||||
// VCN generation/driver — measured on-box rather than assumed.
|
||||
let usage =
|
||||
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
|
||||
opts.set("usage", &usage);
|
||||
opts.set("rc", "cbr");
|
||||
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
|
||||
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
|
||||
// low-latency preset choice on the NVENC path).
|
||||
opts.set("quality", "speed");
|
||||
opts.set("preanalysis", "false");
|
||||
opts.set("enforce_hrd", "true");
|
||||
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
|
||||
opts.set("latency", "true");
|
||||
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
|
||||
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
|
||||
opts.set("bf", "0");
|
||||
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
|
||||
opts.set("header_insertion_mode", "idr");
|
||||
}
|
||||
WinVendor::Qsv => {
|
||||
opts.set("preset", "veryfast");
|
||||
opts.set("async_depth", "1"); // bound in-flight frames — the big QSV latency lever
|
||||
opts.set("low_power", "1"); // VDEnc fixed-function path (lower latency)
|
||||
opts.set("look_ahead", "0"); // (h264_qsv only; ignored on hevc/av1)
|
||||
opts.set("forced_idr", "1"); // a forced key frame becomes a real IDR
|
||||
opts.set("scenario", "displayremoting");
|
||||
}
|
||||
}
|
||||
video
|
||||
.open_with(opts)
|
||||
@@ -382,11 +271,6 @@ 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
|
||||
@@ -466,7 +350,7 @@ impl SystemInner {
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -588,10 +472,19 @@ impl SystemInner {
|
||||
pts: i64,
|
||||
idr: bool,
|
||||
) -> Result<()> {
|
||||
match readback_route(format, self.ten_bit)? {
|
||||
ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
|
||||
ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
|
||||
ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
|
||||
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
anyhow::ensure!(
|
||||
fmt_10 == self.ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
if self.ten_bit { 10 } else { 8 }
|
||||
);
|
||||
match format {
|
||||
PixelFormat::Nv12 | PixelFormat::P010 => self.readback_yuv(frame, pts, idr),
|
||||
PixelFormat::Bgra | PixelFormat::Bgrx => self.readback_bgra(frame, pts, idr),
|
||||
PixelFormat::Rgb10a2 => self.readback_rgb10(frame, pts, idr),
|
||||
other => {
|
||||
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,7 +851,7 @@ impl ZeroCopyInner {
|
||||
bit_depth: u8,
|
||||
device: &ID3D11Device,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -969,7 +862,14 @@ impl ZeroCopyInner {
|
||||
} else {
|
||||
PixelFormat::Nv12
|
||||
};
|
||||
let bind_flags = pool_bind_flags(vendor);
|
||||
// Bind flags on the FFmpeg-allocated pool. AMF reads it as encoder input (RENDER_TARGET +
|
||||
// SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx surface
|
||||
// (DECODER | VIDEO_ENCODER). The CopySubresourceRegion into the pool works with any usable
|
||||
// DEFAULT-usage texture regardless.
|
||||
let bind_flags = match vendor {
|
||||
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||
};
|
||||
const POOL: c_int = 8;
|
||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||
@@ -1411,7 +1311,11 @@ impl Encoder for FfmpegWinEncoder {
|
||||
Some(Inner::ZeroCopy(z)) => &mut z.enc,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let cap_us = poll_spin_cap_us();
|
||||
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 deadline = (cap_us > 0 && self.in_flight > 0)
|
||||
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
|
||||
loop {
|
||||
@@ -1443,193 +1347,3 @@ impl Encoder for FfmpegWinEncoder {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
|
||||
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
|
||||
/// probe-never-assume rule).
|
||||
#[test]
|
||||
fn zerocopy_default_is_per_vendor_and_override_wins() {
|
||||
assert!(zerocopy_active(None, WinVendor::Amf));
|
||||
assert!(!zerocopy_active(None, WinVendor::Qsv));
|
||||
for vendor in [WinVendor::Amf, WinVendor::Qsv] {
|
||||
assert!(zerocopy_active(Some(true), vendor));
|
||||
assert!(!zerocopy_active(Some(false), vendor));
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_FFWIN_POLL_MS` grammar: default 0 (no spin), verbatim ms → µs inside the clamp,
|
||||
/// and the clamp applies BEFORE the µs conversion — the slipped-digit value that used to be a
|
||||
/// 27.7-hour spin resolves to the 1 s cap, not a wedged encode thread.
|
||||
#[test]
|
||||
fn poll_spin_cap_clamps_before_the_us_conversion() {
|
||||
assert_eq!(parse_poll_spin_cap_us(None), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("0")), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("5")), 5_000);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some(" 12 ")), 12_000);
|
||||
assert_eq!(
|
||||
parse_poll_spin_cap_us(Some("100000000")),
|
||||
MAX_POLL_SPIN_MS * 1000
|
||||
);
|
||||
assert_eq!(
|
||||
parse_poll_spin_cap_us(Some(&u64::MAX.to_string())),
|
||||
MAX_POLL_SPIN_MS * 1000
|
||||
);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("junk")), 0);
|
||||
assert_eq!(parse_poll_spin_cap_us(Some("-1")), 0);
|
||||
}
|
||||
|
||||
/// The swscale source map: packed RGB/BGR converts; every YUV/10-bit layout is refused (the
|
||||
/// swscale lane is the 8-bit BGRA fallback, not a general converter — the Linux HDR formats
|
||||
/// are listed explicitly so a `PixelFormat` addition re-breaks the match on purpose).
|
||||
#[test]
|
||||
fn sws_src_accepts_packed_rgb_only() {
|
||||
assert_eq!(sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
|
||||
assert_eq!(sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
|
||||
assert_eq!(sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
|
||||
assert_eq!(sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
|
||||
assert_eq!(sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
|
||||
assert_eq!(sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
|
||||
for f in [
|
||||
PixelFormat::Nv12,
|
||||
PixelFormat::P010,
|
||||
PixelFormat::Rgb10a2,
|
||||
PixelFormat::Yuv444,
|
||||
PixelFormat::X2Rgb10,
|
||||
PixelFormat::X2Bgr10,
|
||||
] {
|
||||
assert!(sws_src(f).is_err(), "{f:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// The readback routing table, and its depth guard: NV12/P010 take the plane copy, the
|
||||
/// video-processor fallbacks take their swscale lanes, a depth CHANGE under the encoder is
|
||||
/// refused (in both directions), and depth-consistent routing never trips the guard.
|
||||
#[test]
|
||||
fn readback_routing_and_depth_guard() {
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Nv12, false).unwrap(),
|
||||
ReadbackRoute::Yuv
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::P010, true).unwrap(),
|
||||
ReadbackRoute::Yuv
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Bgra, false).unwrap(),
|
||||
ReadbackRoute::Bgra
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Bgrx, false).unwrap(),
|
||||
ReadbackRoute::Bgra
|
||||
);
|
||||
assert_eq!(
|
||||
readback_route(PixelFormat::Rgb10a2, true).unwrap(),
|
||||
ReadbackRoute::Rgb10
|
||||
);
|
||||
// Mid-stream depth changes — the genuine error the guard exists for.
|
||||
assert!(readback_route(PixelFormat::P010, false).is_err());
|
||||
assert!(readback_route(PixelFormat::Rgb10a2, false).is_err());
|
||||
assert!(readback_route(PixelFormat::Nv12, true).is_err());
|
||||
assert!(readback_route(PixelFormat::Bgra, true).is_err());
|
||||
// A format neither lane can read back.
|
||||
assert!(readback_route(PixelFormat::Yuv444, false).is_err());
|
||||
}
|
||||
|
||||
/// The 10-bit predicate follows the PIXELS (P010/Rgb10a2), not the negotiated depth — see
|
||||
/// `ten_bit_input` for the forever-failing-session shape the reverse produced here.
|
||||
#[test]
|
||||
fn ten_bit_follows_the_pixels() {
|
||||
assert!(is_10bit_format(PixelFormat::P010));
|
||||
assert!(is_10bit_format(PixelFormat::Rgb10a2));
|
||||
assert!(!is_10bit_format(PixelFormat::Nv12));
|
||||
assert!(!is_10bit_format(PixelFormat::Bgra));
|
||||
assert!(!is_10bit_format(PixelFormat::Bgrx));
|
||||
}
|
||||
|
||||
/// The QSV low-latency contract, pinned: these five knobs are the difference between
|
||||
/// display-remoting latency and transcode behavior — a silent regression here changes every
|
||||
/// Intel Windows session.
|
||||
#[test]
|
||||
fn qsv_opts_pin_the_latency_contract() {
|
||||
let opts = vendor_opts(WinVendor::Qsv, "ignored");
|
||||
let get = |k: &str| {
|
||||
opts.iter()
|
||||
.find(|(key, _)| *key == k)
|
||||
.map(|(_, v)| v.as_str())
|
||||
};
|
||||
assert_eq!(get("async_depth"), Some("1"));
|
||||
assert_eq!(get("low_power"), Some("1"));
|
||||
assert_eq!(get("look_ahead"), Some("0"));
|
||||
assert_eq!(get("forced_idr"), Some("1"));
|
||||
assert_eq!(get("scenario"), Some("displayremoting"));
|
||||
assert_eq!(get("preset"), Some("veryfast"));
|
||||
assert_eq!(get("usage"), None, "AMF-only knob must not leak into QSV");
|
||||
}
|
||||
|
||||
/// The AMF (benchmark-comparator) contract: usage passes through, B-frames are pinned OFF
|
||||
/// (each one is a full frame period of latency on RDNA3+), and the low-latency submission
|
||||
/// mode + IDR header insertion are requested.
|
||||
#[test]
|
||||
fn amf_opts_pin_no_bframes_and_the_usage_passthrough() {
|
||||
let opts = vendor_opts(WinVendor::Amf, "lowlatency");
|
||||
let get = |k: &str| {
|
||||
opts.iter()
|
||||
.find(|(key, _)| *key == k)
|
||||
.map(|(_, v)| v.as_str())
|
||||
};
|
||||
assert_eq!(get("usage"), Some("lowlatency"));
|
||||
assert_eq!(get("bf"), Some("0"));
|
||||
assert_eq!(get("rc"), Some("cbr"));
|
||||
assert_eq!(get("quality"), Some("speed"));
|
||||
assert_eq!(get("latency"), Some("true"));
|
||||
assert_eq!(get("header_insertion_mode"), Some("idr"));
|
||||
assert_eq!(get("preanalysis"), Some("false"));
|
||||
assert_eq!(get("enforce_hrd"), Some("true"));
|
||||
}
|
||||
|
||||
/// The zero-copy pool's bind flags per vendor — AMF's encoder-input shape vs QSV's mfx
|
||||
/// surface shape (a wrong flag set fails `av_hwframe_ctx_init`, or worse, opens and maps
|
||||
/// wrong).
|
||||
#[test]
|
||||
fn pool_bind_flags_per_vendor() {
|
||||
assert_eq!(
|
||||
pool_bind_flags(WinVendor::Amf),
|
||||
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32
|
||||
);
|
||||
assert_eq!(
|
||||
pool_bind_flags(WinVendor::Qsv),
|
||||
(D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32
|
||||
);
|
||||
}
|
||||
|
||||
/// The libavcodec encoder-name dispatch (name-selected — the codec id would pick the
|
||||
/// software encoder).
|
||||
#[test]
|
||||
fn encoder_names_dispatch_by_vendor() {
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H264), "h264_qsv");
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H265), "hevc_qsv");
|
||||
assert_eq!(WinVendor::Qsv.encoder_name(Codec::Av1), "av1_qsv");
|
||||
assert_eq!(WinVendor::Amf.encoder_name(Codec::H265), "hevc_amf");
|
||||
}
|
||||
|
||||
/// Probe smoke: resolve the QSV probe on this machine without crashing — `false` on a box
|
||||
/// without the Intel runtime is a valid outcome; the value is printed, not asserted. Only
|
||||
/// compiled in the `amf-qsv`-without-`qsv` combo (the shipped combo answers via native VPL).
|
||||
/// Run on the Windows CI runner:
|
||||
/// cargo test -p pf-encode --no-default-features --features amf-qsv -- --ignored ffmpeg_win
|
||||
#[cfg(not(feature = "qsv"))]
|
||||
#[test]
|
||||
#[ignore = "needs a real FFmpeg runtime probe (run on the Windows CI runner, not a dev box)"]
|
||||
fn ffmpeg_win_probe_smoke() {
|
||||
for codec in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
eprintln!(
|
||||
"probe_can_encode(Qsv, {codec:?}) = {}",
|
||||
probe_can_encode(WinVendor::Qsv, codec)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling,
|
||||
CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -264,112 +263,6 @@ 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
|
||||
@@ -386,24 +279,12 @@ 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 {
|
||||
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)
|
||||
})
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.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
|
||||
@@ -442,23 +323,7 @@ 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
|
||||
@@ -469,13 +334,9 @@ 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), wait_ms) != WAIT_OBJECT_0 {
|
||||
wedged = true;
|
||||
Err(format!(
|
||||
"NVENC completion event timeout ({wait_ms} ms) — encoder wedged?"
|
||||
))
|
||||
if WaitForSingleObject(HANDLE(job.event as *mut c_void), 5000) != WAIT_OBJECT_0 {
|
||||
Err("NVENC completion event timeout (5s) — encoder wedged?".to_string())
|
||||
} 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,
|
||||
@@ -526,12 +387,6 @@ 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,
|
||||
@@ -539,17 +394,6 @@ 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
|
||||
@@ -603,11 +447,6 @@ pub struct NvencD3d11Encoder {
|
||||
/// `reconfigure_bitrate` must present the SAME init params as the open (only the config's
|
||||
/// rate fields may move). Meaningless while `inited` is false.
|
||||
split_mode: u32,
|
||||
/// The session's arbitrated sub-frame state ([`resolve_split_subframe`] at open) — recorded
|
||||
/// so reconfigure presents EXACTLY the init params the session opened with (an env re-read
|
||||
/// there could flip `enableSubFrameWrite` mid-session, and would re-log the arbitration on
|
||||
/// every ABR retarget).
|
||||
subframe_on: bool,
|
||||
session_async: bool,
|
||||
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
|
||||
/// loss event (the client resends until it sees recovery).
|
||||
@@ -616,11 +455,6 @@ 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`.
|
||||
@@ -670,9 +504,6 @@ 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,
|
||||
@@ -691,11 +522,9 @@ impl NvencD3d11Encoder {
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
subframe_on: false,
|
||||
session_async: false,
|
||||
last_rfi_range: None,
|
||||
init_device: ptr::null_mut(),
|
||||
init_device_com: None,
|
||||
session_units: 0,
|
||||
})
|
||||
}
|
||||
@@ -742,41 +571,18 @@ impl NvencD3d11Encoder {
|
||||
for &bs in &self.bitstreams {
|
||||
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
// 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();
|
||||
@@ -835,9 +641,6 @@ 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);
|
||||
@@ -865,12 +668,7 @@ 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 {
|
||||
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;
|
||||
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
|
||||
self.bit_depth = 8;
|
||||
self.hdr = false;
|
||||
}
|
||||
@@ -956,27 +754,6 @@ 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.
|
||||
@@ -986,7 +763,6 @@ impl NvencD3d11Encoder {
|
||||
bitrate: u64,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
subframe: bool,
|
||||
) -> Result<*mut c_void> {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
@@ -1004,7 +780,6 @@ 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,
|
||||
@@ -1021,10 +796,9 @@ impl NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
enable_async,
|
||||
// Windows: env opt-in only ("1"), never a default; arbitrated against split by the
|
||||
// caller (resolve_split_subframe) — and build_init_params additionally refuses it
|
||||
// on an async session.
|
||||
subframe,
|
||||
// Windows: env opt-in only ("1"), never a default — and build_init_params
|
||||
// additionally refuses it on an async session.
|
||||
resolve_subframe(false),
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
@@ -1038,15 +812,6 @@ 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`,
|
||||
@@ -1069,19 +834,43 @@ 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.
|
||||
// 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.
|
||||
// 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.
|
||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. On Windows
|
||||
// sub-frame is env-opt-in only, so resolved == forced by construction.
|
||||
let subframe_req = resolve_subframe(false);
|
||||
let (split_mode, subframe_req) =
|
||||
resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_req);
|
||||
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"
|
||||
);
|
||||
// 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
|
||||
@@ -1095,84 +884,39 @@ impl NvencD3d11Encoder {
|
||||
// built in the right mode from the start.
|
||||
let use_async = self.async_supported && async_retrieve_requested();
|
||||
|
||||
// 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, subframe_req);
|
||||
// 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,
|
||||
subframe_req,
|
||||
);
|
||||
}
|
||||
let mut 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, target_bps, no_split, use_async, subframe_req)
|
||||
{
|
||||
if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) {
|
||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||
used_split = no_split;
|
||||
split_mode = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = target_bps;
|
||||
self.bitrate_bps = requested_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 = target_bps;
|
||||
let mut hi = requested_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,
|
||||
used_split,
|
||||
use_async,
|
||||
subframe_req,
|
||||
) {
|
||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -1181,15 +925,7 @@ impl NvencD3d11Encoder {
|
||||
best_bps = mid;
|
||||
lo = 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);
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
@@ -1197,30 +933,14 @@ 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 = match self.try_open_session(
|
||||
device,
|
||||
FLOOR_BPS,
|
||||
used_split,
|
||||
use_async,
|
||||
subframe_req,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self
|
||||
.try_open_session(
|
||||
device,
|
||||
FLOOR_BPS,
|
||||
no_split,
|
||||
use_async,
|
||||
subframe_req,
|
||||
)
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
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_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -1228,23 +948,21 @@ 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;
|
||||
// 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.subframe_on = subframe_req;
|
||||
// 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;
|
||||
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(used_split);
|
||||
self.session_units = split_mode_units(split_mode);
|
||||
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.)
|
||||
@@ -1268,12 +986,6 @@ 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,
|
||||
@@ -1282,6 +994,7 @@ 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>();
|
||||
@@ -1373,11 +1086,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let dev_raw = frame.device.as_raw();
|
||||
let size_changed =
|
||||
self.inited && (self.width != captured.width || self.height != captured.height);
|
||||
// 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;
|
||||
let hdr_changed = self.inited && self.hdr != hdr;
|
||||
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
|
||||
tracing::info!(
|
||||
device_changed = self.init_device != dev_raw,
|
||||
@@ -1398,13 +1107,7 @@ 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
|
||||
@@ -1430,11 +1133,9 @@ 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
|
||||
// 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.
|
||||
// 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.
|
||||
if self.chroma_444
|
||||
&& !matches!(
|
||||
self.buffer_fmt,
|
||||
@@ -1480,23 +1181,9 @@ 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().min(UNCONFIGURED_RING_DEPTH),
|
||||
None => async_inflight_cap(),
|
||||
};
|
||||
while self.async_rt.is_some() && self.pending.len() >= cap {
|
||||
let done = {
|
||||
@@ -1690,14 +1377,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// RFI is probed once at open (`rfi_supported`) — the real capability the session glue
|
||||
// routes on. (In-band HDR SEI needs no cap: it rides keyframes on HEVC/H.264 HDR
|
||||
// sessions — see `submit` — and every first-party client reads the grade out-of-band
|
||||
// via the 0xCE datagram regardless.)
|
||||
// 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
|
||||
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
|
||||
// datagram. Don't claim a capability the AV1 path doesn't have.
|
||||
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
|
||||
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
|
||||
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
|
||||
chroma_444: self.chroma_444,
|
||||
@@ -1716,56 +1404,61 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// 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 {
|
||||
// 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 {
|
||||
return false;
|
||||
}
|
||||
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 => {
|
||||
// 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 {
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
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
|
||||
return 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
|
||||
}
|
||||
}
|
||||
}
|
||||
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>> {
|
||||
@@ -1859,15 +1552,6 @@ 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
|
||||
@@ -1892,10 +1576,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
self.session_async,
|
||||
// The SESSION's recorded state, not a fresh env read: reconfigure must
|
||||
// present exactly the init params the open arbitrated (an env re-read could
|
||||
// flip enableSubFrameWrite mid-session — the Linux latch invariant).
|
||||
self.subframe_on,
|
||||
resolve_subframe(false),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1919,12 +1600,6 @@ 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.
|
||||
}
|
||||
@@ -1970,11 +1645,6 @@ 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,
|
||||
@@ -2053,9 +1723,6 @@ 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,15 +189,12 @@ impl PyroWaveEncoder {
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
// 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) {
|
||||
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.
|
||||
bail!(
|
||||
"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" }
|
||||
"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"
|
||||
);
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
@@ -297,10 +294,8 @@ 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 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.
|
||||
/// 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.
|
||||
///
|
||||
/// # Safety
|
||||
/// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created
|
||||
@@ -351,11 +346,7 @@ 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 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.
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close it ourselves on failure.
|
||||
let _ = CloseHandle(handle);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -425,9 +416,7 @@ impl PyroWaveEncoder {
|
||||
pw::pyrowave_sync_object_create(&info, &mut sync),
|
||||
"sync_object_create",
|
||||
) {
|
||||
// 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.
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close the dup on failure.
|
||||
let _ = CloseHandle(dup);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -689,8 +678,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -313,15 +313,11 @@ fn create_session(target_luid: Option<[u8; 8]>) -> Result<(Loader, Session, (u16
|
||||
.unwrap_or(&impls[0]);
|
||||
if let Some(want) = target_luid {
|
||||
if !(chosen.luid_valid && chosen.luid == want) {
|
||||
// Static configuration mismatch — the capture adapter has no Intel VPL
|
||||
// implementation, so every rebuild re-hits this same wall. The terminal marker
|
||||
// makes the stream loop end the session at once instead of spending its reset
|
||||
// budget (5 futile rebuilds, then an opaque loop as the client reconnects).
|
||||
return Err(anyhow::Error::new(super::TerminalEncoderError).context(
|
||||
bail!(
|
||||
"capture device's adapter is not an Intel VPL implementation (hybrid box? \
|
||||
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the \
|
||||
Intel adapter for a QSV session)",
|
||||
));
|
||||
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the Intel \
|
||||
adapter for a QSV session)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// SAFETY: `loader.0` is live; `MFXCreateSession` fills `session` only on success and the
|
||||
@@ -523,20 +519,9 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
}
|
||||
b.WhitePointX = m.white_point[0];
|
||||
b.WhitePointY = m.white_point[1];
|
||||
// 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;
|
||||
// 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;
|
||||
b.MinDisplayMasteringLuminance = m.min_display_mastering_luminance;
|
||||
b
|
||||
});
|
||||
@@ -705,17 +690,13 @@ impl Inner {
|
||||
}
|
||||
|
||||
fn take_bs(&mut self) -> Box<BsBuf> {
|
||||
// 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 {
|
||||
match self.bs_pool.pop() {
|
||||
Some(mut b) => {
|
||||
b.recycle();
|
||||
return b;
|
||||
b
|
||||
}
|
||||
None => BsBuf::new(self.bs_bytes),
|
||||
}
|
||||
BsBuf::new(self.bs_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,12 +774,7 @@ 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");
|
||||
}
|
||||
// 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);
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
if ten_bit && codec == Codec::H264 {
|
||||
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
|
||||
}
|
||||
@@ -902,8 +878,7 @@ impl QsvEncoder {
|
||||
if sts > vpl::MFX_ERR_NONE {
|
||||
tracing::debug!(status = sts_name(sts), "QSV Init returned a warning");
|
||||
}
|
||||
// Whether we ASKED for intra-refresh. Not the same as getting it — confirmed below.
|
||||
let ir_requested = cfg.intra_refresh && set.co2.is_some();
|
||||
let ir_active = 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 {
|
||||
@@ -917,57 +892,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -1420,30 +1344,36 @@ impl Encoder for QsvEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
match plan.anchor {
|
||||
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 {
|
||||
Some((slot, ltr_frame)) => {
|
||||
self.pending_force = Some(slot);
|
||||
tracing::info!(
|
||||
@@ -1472,9 +1402,10 @@ 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.
|
||||
supports_hdr_metadata: self.ten_bit && self.codec != Codec::H264,
|
||||
chroma_444: false,
|
||||
intra_refresh: self.ir_active,
|
||||
// Unvalidated on-glass — the host keeps the IDR recovery path until then.
|
||||
@@ -1645,38 +1576,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
+276
-984
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user