Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491a344f23 |
+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
|
||||
|
||||
+30
-24
@@ -6,12 +6,17 @@
|
||||
#
|
||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
||||
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
||||
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
||||
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
||||
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
||||
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
||||
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
||||
#
|
||||
# punktfunk.zip
|
||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||
# plugin.json [required]
|
||||
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||
# main.py [required: python backend]
|
||||
# dist/index.js [required: rollup output]
|
||||
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||
# README.md (recommended)
|
||||
# LICENSE [required by the plugin store]
|
||||
#
|
||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||
@@ -85,27 +90,28 @@ jobs:
|
||||
- name: Assemble store-layout zip
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
||||
# so an image change can't silently break the build.
|
||||
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
||||
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
||||
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
||||
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
||||
bash clients/decky/scripts/package.sh
|
||||
DEST="clients/decky/out/$PLUGIN"
|
||||
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
||||
# check_update). It points at THIS channel's manifest.json (published below); that
|
||||
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
||||
# across future alias re-uploads.
|
||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||
STAGE="$RUNNER_TEMP/decky"
|
||||
DEST="$STAGE/$PLUGIN"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp clients/decky/plugin.json "$DEST/"
|
||||
cp clients/decky/package.json "$DEST/"
|
||||
cp clients/decky/main.py "$DEST/"
|
||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||
cp clients/decky/README.md "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||
cp LICENSE-MIT "$DEST/LICENSE"
|
||||
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
||||
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
||||
controller_config/punktfunk.vdf update.json; do
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
||||
done
|
||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||
|
||||
@@ -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,21 +73,8 @@ 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)
|
||||
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.
|
||||
cat /etc/resolv.conf || true
|
||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
|
||||
# (https://git.unom.io/api/packages/unom/npm/).
|
||||
#
|
||||
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
|
||||
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
|
||||
#
|
||||
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
|
||||
# built BEFORE the kit's `bun install` copies it.
|
||||
#
|
||||
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
|
||||
name: plugin-kit-publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['plugin-kit-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
|
||||
# fetch needs git + ca-certificates, and the version-guard step below uses node.
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build the SDK (file:../sdk dependency source)
|
||||
working-directory: sdk
|
||||
run: |
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
|
||||
- name: Test
|
||||
working-directory: plugin-kit
|
||||
run: bun test
|
||||
|
||||
- name: Build (dist/ JS + .d.ts + theme.css)
|
||||
working-directory: plugin-kit
|
||||
run: bun run build
|
||||
|
||||
- name: Tag matches package version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
|
||||
PKG="$(node -p "require('./package.json').version")"
|
||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
|
||||
|
||||
- name: Publish to Gitea registry
|
||||
working-directory: plugin-kit
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
|
||||
bun publish
|
||||
@@ -149,26 +149,6 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune Xcode DerivedData
|
||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
@@ -196,7 +176,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
@@ -294,7 +273,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -358,7 +336,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -393,76 +370,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.
|
||||
@@ -487,7 +394,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+27
-98
@@ -656,30 +656,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -715,7 +691,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1459,16 +1434,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 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2839,12 +2804,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.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2868,7 +2832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2917,7 +2881,6 @@ dependencies = [
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-capture",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
@@ -2931,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2929,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2981,7 +2944,6 @@ dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-host-config",
|
||||
"pf-paths",
|
||||
"pf-win-display",
|
||||
"punktfunk-core",
|
||||
"reis",
|
||||
"tokio",
|
||||
@@ -2999,14 +2961,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,11 +2983,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -3052,7 +3013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3064,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3034,6 @@ dependencies = [
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3176,17 +3136,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -3272,7 +3221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3288,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3304,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3319,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3338,12 +3287,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
"cbindgen",
|
||||
"chacha20poly1305",
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
@@ -3370,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3415,13 +3363,11 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reis",
|
||||
"ring",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rusty_enet",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3454,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3468,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3491,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.19.2"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -5900,23 +5846,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.19.2"
|
||||
version = "0.15.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+6
-1577
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -404,14 +404,7 @@ fn feeder_loop(
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
|
||||
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
|
||||
@@ -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:
|
||||
@@ -595,21 +579,13 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
||||
// longer contains the client-queue wait...
|
||||
// host/network split — drained by the 1 s stats tick).
|
||||
split.recordReceipt(
|
||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||
// ...which is measured as its own term instead (receipt→pull, both
|
||||
// client-local).
|
||||
queue.record(
|
||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
||||
offsetNs: 0)
|
||||
},
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
@@ -990,9 +966,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
|
||||
}
|
||||
@@ -102,12 +102,6 @@ final class SessionModel: ObservableObject {
|
||||
@Published var decodeValid = false
|
||||
@Published var displayP50Ms = 0.0
|
||||
@Published var displayValid = false
|
||||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
||||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
||||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
||||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
||||
@Published var clientQueueP50Ms = 0.0
|
||||
@Published var clientQueueValid = false
|
||||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
||||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
||||
@@ -153,9 +147,6 @@ final class SessionModel: ObservableObject {
|
||||
let endToEnd = LatencyMeter()
|
||||
let decodeStage = LatencyMeter()
|
||||
let displayStage = LatencyMeter()
|
||||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
||||
/// drained by the same 1 s tick as the stage meters.
|
||||
let clientQueue = LatencyMeter()
|
||||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
||||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
||||
let presentFloor = LatencyMeter()
|
||||
@@ -196,11 +187,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 +293,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 +433,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
|
||||
@@ -518,7 +489,6 @@ final class SessionModel: ObservableObject {
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
@@ -557,7 +527,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.
|
||||
@@ -710,12 +679,6 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.osFloorValid = false
|
||||
}
|
||||
if let q = self.clientQueue.drain() {
|
||||
self.clientQueueP50Ms = q.p50Ms
|
||||
self.clientQueueValid = true
|
||||
} else {
|
||||
self.clientQueueValid = false
|
||||
}
|
||||
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||
@@ -726,12 +689,9 @@ final class SessionModel: ObservableObject {
|
||||
// captured before the 2026-07 floor policy); the appended trio carries the
|
||||
// measured OS present floor and the floor-shaved values the HUD displays.
|
||||
let line = String(
|
||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
||||
// the whole line (a cascade error that also mis-blames the float args).
|
||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -742,8 +702,7 @@ final class SessionModel: ObservableObject {
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,16 +118,6 @@ struct StreamHUDView: View {
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
||||
// is a client-side standing backlog that pre-split builds displayed as
|
||||
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
|
||||
// bleed logs alongside when it acts on the same state.
|
||||
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
|
||||
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} else if model.hostNetworkValid {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
|
||||
@@ -43,9 +43,6 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -348,22 +345,6 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
+ "latency cost. Fullscreen always uses the fastest path.",
|
||||
value: $windowedSafePresent),
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
|
||||
@@ -300,18 +300,6 @@ extension SettingsView {
|
||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
||||
Toggle("V-Sync", isOn: $vsync)
|
||||
}
|
||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
||||
// affected setups, so the caption says so in plain words.
|
||||
described(windowedSafePresent
|
||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
||||
+ "cost. Fullscreen always uses the fastest path."
|
||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
||||
+ "restarts during windowed streaming.") {
|
||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -447,14 +435,6 @@ extension SettingsView {
|
||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||
@ViewBuilder var inputSection: some View {
|
||||
Section("Keyboard & mouse") {
|
||||
#if os(macOS)
|
||||
described(mouseModeDescription) {
|
||||
Picker("Mouse input", selection: $mouseMode) {
|
||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
||||
Picker("Modifier keys", selection: $modifierLayout) {
|
||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||
@@ -467,20 +447,6 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
||||
case .capture:
|
||||
return "The pointer locks to the stream and sends relative motion — best for "
|
||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
||||
case .desktop:
|
||||
return "The pointer moves freely in and out of the stream and sends absolute "
|
||||
+ "positions — best for remote desktop work. Unavailable on gamescope hosts."
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
@@ -40,7 +40,6 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||
@@ -89,7 +88,6 @@ struct SettingsView: View {
|
||||
@State var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
|
||||
/// decrypted — `PunktfunkFrame.received_ns`, ABI v9) — the **received** measurement point of
|
||||
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
|
||||
/// pre-decode hand-off wait into the network term, which is how the 2026-07 two-pair
|
||||
/// standing-latency plateau hid as "network". The decode stage is `decodedNs - receivedNs`,
|
||||
/// both client-local (no skew offset applies).
|
||||
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant this pull returned. `pulledNs - receivedNs` is the
|
||||
/// client-queue wait (kernel hand-off + FrameChannel dwell) — the term the HUD splits out
|
||||
/// so a client-side standing backlog can never masquerade as network latency again.
|
||||
public let pulledNs: Int64
|
||||
|
||||
/// `pulledNs` defaults to `receivedNs` (zero queue wait) for callers with no pull instant —
|
||||
/// the synthetic probe AUs and decode tests, where the split is meaningless.
|
||||
public init(
|
||||
data: Data, ptsNs: UInt64, frameIndex: UInt32, flags: UInt32,
|
||||
receivedNs: Int64, pulledNs: Int64? = nil
|
||||
) {
|
||||
self.data = data
|
||||
self.ptsNs = ptsNs
|
||||
self.frameIndex = frameIndex
|
||||
self.flags = flags
|
||||
self.receivedNs = receivedNs
|
||||
self.pulledNs = pulledNs ?? receivedNs
|
||||
}
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
@@ -221,9 +200,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 +360,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 +396,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 +415,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)
|
||||
}
|
||||
}
|
||||
@@ -787,16 +662,11 @@ public final class PunktfunkConnection {
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
// Receipt = the core's reassembly-completion stamp (ABI v9); the pull instant is
|
||||
// kept separately so the client-queue wait is its own measured term. 0 would mean a
|
||||
// pre-v9 core — impossible here (core and Kit ship in one binary), but fall back to
|
||||
// the pull instant rather than record a 1970 receipt.
|
||||
let receivedNs = frame.received_ns > 0 ? Int64(frame.received_ns) : pulledNs
|
||||
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
||||
receivedNs: receivedNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
@@ -1141,19 +1011,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 +1033,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
|
||||
|
||||
@@ -110,11 +110,11 @@ public final class InputCapture {
|
||||
/// event itself is swallowed). Main queue.
|
||||
public var onToggleCapture: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌥⇧M (the mouse-model flip, capture ⇄ desktop — cross-client parity with the
|
||||
/// SDL clients' Ctrl+Alt+Shift+M; detected here, like ⌘⎋, so it works regardless of the
|
||||
/// current capture state and the event itself is swallowed). macOS only; the
|
||||
/// absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||
public var onToggleMouseMode: (() -> Void)?
|
||||
/// Fired on ⌘⇧C (the client-side-cursor toggle — flips between the captured/disassociated
|
||||
/// relative path and the visible-cursor absolute path; detected here, like ⌘⎋, so it works
|
||||
/// regardless of the current capture state and the event itself is swallowed). macOS only;
|
||||
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||
public var onToggleCursor: (() -> Void)?
|
||||
|
||||
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||
@@ -245,14 +245,13 @@ public final class InputCapture {
|
||||
self.onToggleCapture?()
|
||||
return nil
|
||||
}
|
||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop — the SDL clients' identical
|
||||
// chord). Detected in both capture states, like ⌘⎋, so the model can be set
|
||||
// before engaging. keyCode 46 = kVK_ANSI_M; layout-independent. Suppress the M
|
||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
||||
// event so it doesn't beep.
|
||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
||||
self.onToggleMouseMode?()
|
||||
// ⌘⇧C toggles the client-side cursor (visible-cursor absolute path vs the
|
||||
// captured relative path). keyCode 8 = kVK_ANSI_C; layout-independent so it
|
||||
// fires the same on any keyboard. Suppress the C (latched like ⌘⎋'s Esc) so it
|
||||
// doesn't type into the host, and swallow the event so it doesn't beep.
|
||||
if event.keyCode == 8 /* C */, flags == [.command, .shift] {
|
||||
self.suppressedVK = 0x43 // VK_C — the same physical C is en route via GC
|
||||
self.onToggleCursor?()
|
||||
return nil
|
||||
}
|
||||
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/// How a physical mouse drives the host — the cross-client mouse model (the SDL clients'
|
||||
/// `MouseMode` / `Settings::mouse_mode`, design/remote-desktop-sweep.md M1). Stored stringly
|
||||
/// under `DefaultsKey.mouseMode`.
|
||||
public enum MouseInputMode: String, CaseIterable, Sendable {
|
||||
/// Pointer capture (disassociated, hidden cursor, relative deltas) — the game model,
|
||||
/// and the default: the only cursor you see is the host's.
|
||||
case capture
|
||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
||||
/// motion is forwarded as absolute positions through the letterbox. The remote desktop
|
||||
/// model. Requires a host injector with absolute support (not gamescope).
|
||||
case desktop
|
||||
}
|
||||
@@ -23,34 +23,6 @@ import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
#if os(macOS)
|
||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
||||
/// "mismatched swapID's" kernel-panic saga's mechanism picker. Fullscreen always presents
|
||||
/// `async` (direct-scanout promotion, lowest latency, no panic reports there); the windowed
|
||||
/// mechanism is resolved per session by SessionPresenter (user setting +
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env override) and routed here via `setWindowedPresent`.
|
||||
///
|
||||
/// - `async`: the CAMetalLayer image queue (`commandBuffer.present`) — the fastest composited
|
||||
/// path and the PANIC TRIGGER on high-refresh displays (the out-of-band swaps race
|
||||
/// WindowServer's compositor; it survived glass pacing and every codec).
|
||||
/// - `transaction`: `CAMetalLayer.presentsWithTransaction` — the swap commits WITH the layer
|
||||
/// tree, in lockstep with the compositor (Apple's documented remedy; validated no-panic on
|
||||
/// the 240 Hz repro machine). The present is committed from the RENDER thread inside an
|
||||
/// explicit CATransaction + flush — see `encodePresent` for why that beats the original
|
||||
/// main-thread hop.
|
||||
/// - `surface`: no image queue at all — render into a pooled IOSurface and swap it into a plain
|
||||
/// CALayer's `contents` (the f407f418 PyroWave mitigation, resurrected format-aware:
|
||||
/// rgba16Float + PQ tagging keeps HDR). WindowServer treats it as ordinary layer damage on
|
||||
/// its own composite cadence. PROTOTYPE: whether the compositor honors PQ/EDR for plain-layer
|
||||
/// IOSurface contents still needs an on-glass eyeball — the metal layer stays underneath with
|
||||
/// `wantsExtendedDynamicRangeContent` as the EDR anchor.
|
||||
enum WindowedPresentMode: String, Sendable {
|
||||
case async
|
||||
case transaction
|
||||
case surface
|
||||
}
|
||||
#endif
|
||||
|
||||
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
@@ -226,8 +198,8 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
|
||||
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `WindowedPresentMode`.
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||
static inline float3 pqToSdr(float3 pq) {
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
@@ -258,7 +230,8 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
|
||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom. The passthrough planar
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||
@@ -286,51 +259,21 @@ public final class MetalVideoPresenter {
|
||||
public let layer: CAMetalLayer
|
||||
|
||||
#if os(macOS)
|
||||
/// WINDOWED-mode present coordination — the macOS DCP KERNEL PANIC mitigation.
|
||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||
///
|
||||
/// The panic ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer dies, machine reboots):
|
||||
/// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` — an
|
||||
/// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's
|
||||
/// compositor on a high-refresh COMPOSITED (windowed) session — the compositor's notion of the
|
||||
/// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass
|
||||
/// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio
|
||||
/// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21 —
|
||||
/// so it is the async image queue itself, at any pacing or codec, not a present rate.
|
||||
///
|
||||
/// The fix keeps the full render path (rgba16Float / PQ / EDR — real HDR is preserved) and
|
||||
/// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it
|
||||
/// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then
|
||||
/// call `drawable.present()` INSIDE a CATransaction — the present is enrolled in Core
|
||||
/// Animation's transaction and committed together with the layer tree, so the swap stays in
|
||||
/// lockstep with the compositor instead of racing it (Apple's documented remedy for Metal
|
||||
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
|
||||
/// promotion, lowest latency, no compositor and no panic reports there).
|
||||
///
|
||||
/// 2026-07-21 latency rework: the mitigation MECHANISM is now a three-way pick
|
||||
/// (`WindowedPresentMode`) and the transactional present commits from the RENDER thread —
|
||||
/// see `encodePresent`. Staged under `stagingLock` (main pushes it via
|
||||
/// `setComposited`→`setWindowedPresent`); the render thread drains it and toggles the layer
|
||||
/// property + present style. `Active` is the render-thread copy so the layer property flips
|
||||
/// exactly once per mode change.
|
||||
private var windowedPresentStaged: WindowedPresentMode = .async
|
||||
private var windowedPresentActive: WindowedPresentMode = .async
|
||||
|
||||
/// PUNKTFUNK_TXN_PRESENT=main — the ORIGINAL transactional present (commit →
|
||||
/// waitUntilScheduled → hop to the MAIN thread and present inside its CATransaction), kept
|
||||
/// as a field A/B lever. The default is the render-thread commit: the present harness
|
||||
/// (2026-07-21, this saga) measured the main hop landing a runloop turn late on a busy main
|
||||
/// thread, and an ACTIVE implicit transaction there NESTS the explicit one — presents batch
|
||||
/// at runloop-iteration rate (the field's presents=55 @ fps=240, display_p50 18.6 ms).
|
||||
/// Off-main commits measured immune to main-thread churn (~10 ms glass p50 at 240 Hz
|
||||
/// full-size vs 14+ ms under a churned main hop).
|
||||
private let txnPresentOnMain =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_TXN_PRESENT"] == "main"
|
||||
|
||||
/// The WINDOWED-mode `surface` present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it by SessionPresenter), fed IOSurfaces via `contents` inside explicit
|
||||
/// CATransactions. Transparent (nil contents) whenever surface mode is off, so the metal
|
||||
/// layer below shows through. See `WindowedPresentMode.surface`.
|
||||
let surfaceLayer: CALayer = {
|
||||
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||
public let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
@@ -338,8 +281,8 @@ public final class MetalVideoPresenter {
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed surface-present pool. All pool state
|
||||
/// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap).
|
||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
@@ -349,52 +292,15 @@ public final class MetalVideoPresenter {
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfacePoolHDR = false
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
|
||||
/// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis
|
||||
/// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present,
|
||||
/// and how many presents/swaps were issued. The pf-present line shows the GLASS side
|
||||
/// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a
|
||||
/// windowed mechanism is active (zero cost fullscreen). Lock-guarded: transaction mode
|
||||
/// records from the render thread, surface mode from Metal completion threads.
|
||||
private final class WindowedPresentDiag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var presents = 0
|
||||
private var schedMs: [Double] = []
|
||||
private var commitMs: [Double] = []
|
||||
private var last = CACurrentMediaTime()
|
||||
|
||||
func record(schedMs sched: Double, commitMs commit: Double, mode: WindowedPresentMode) {
|
||||
lock.lock()
|
||||
presents += 1
|
||||
schedMs.append(sched)
|
||||
commitMs.append(commit)
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
last = now
|
||||
let sSched = schedMs.sorted()
|
||||
let sCommit = commitMs.sorted()
|
||||
let line = String(
|
||||
format: "pf-windowed mode=%@ presents=%d schedMs p50=%.2f max=%.2f "
|
||||
+ "commitMs p50=%.2f max=%.2f",
|
||||
mode.rawValue, presents, sSched[sSched.count / 2], sSched.last ?? 0,
|
||||
sCommit[sCommit.count / 2], sCommit.last ?? 0)
|
||||
presents = 0
|
||||
schedMs.removeAll(keepingCapacity: true)
|
||||
commitMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
presenterLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private let windowedDiag = WindowedPresentDiag()
|
||||
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||
private var surfacePresentsStaged = false
|
||||
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
@@ -410,7 +316,7 @@ public final class MetalVideoPresenter {
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||
/// bgra8; tvOS without HDR headroom).
|
||||
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
@@ -685,14 +591,13 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed present mechanism (MAIN thread — the hosting view pushes its window
|
||||
/// state on every layout; SessionPresenter resolves the mechanism per session). `.async` =
|
||||
/// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` /
|
||||
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms — see `WindowedPresentMode`.
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
stagingLock.lock()
|
||||
windowedPresentStaged = mode
|
||||
surfacePresentsStaged = on
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
@@ -829,12 +734,36 @@ public final class MetalVideoPresenter {
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
#if os(macOS)
|
||||
let surfaceMode = surfacePresentsStaged
|
||||
#endif
|
||||
stagingLock.unlock()
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path —
|
||||
// including macOS windowed sessions, which keep real HDR (the DCP mitigation is the
|
||||
// transactional present in `encodePresent`, not a colour downgrade).
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||
#if os(macOS)
|
||||
configure(hdr: planes.pq && !surfaceMode)
|
||||
#else
|
||||
configure(hdr: planes.pq)
|
||||
#endif
|
||||
var csc = planes.csc
|
||||
#if os(macOS)
|
||||
if surfaceMode != surfacePresentsActive {
|
||||
surfacePresentsActive = surfaceMode
|
||||
presenterLog.info(
|
||||
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||
if !surfaceMode {
|
||||
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||
// and re-entering windowed mode rebuilds it in one frame.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
}
|
||||
if surfaceMode {
|
||||
return renderPlanarToSurface(
|
||||
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||
}
|
||||
#endif
|
||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||
@@ -863,6 +792,118 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||
private func renderPlanarToSurface(
|
||||
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||
onPresented: ((Int64?) -> Void)?
|
||||
) -> Bool {
|
||||
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
ensureSurfacePool(size: targetSize)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
onPresented?(
|
||||
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||
private func ensureSurfacePool(size: CGSize) {
|
||||
guard size != surfacePoolSize else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: 4,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
@@ -895,36 +936,6 @@ public final class MetalVideoPresenter {
|
||||
#if DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Windowed (composited) → the DCP swapID-panic mitigation mechanism (see
|
||||
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
|
||||
// vend matches how it will be presented; drained here on the render thread, flipped
|
||||
// exactly once per mode change.
|
||||
stagingLock.lock()
|
||||
let windowedMode = windowedPresentStaged
|
||||
stagingLock.unlock()
|
||||
if windowedMode != windowedPresentActive {
|
||||
windowedPresentActive = windowedMode
|
||||
layer.presentsWithTransaction = windowedMode == .transaction
|
||||
if windowedMode != .surface, !surfacePool.isEmpty {
|
||||
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool — at 5K
|
||||
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
|
||||
// clears the surface layer's contents on main.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
presenterLog.info(
|
||||
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
|
||||
}
|
||||
if windowedMode == .surface {
|
||||
// No image queue at all: render into a pooled IOSurface and swap it into the
|
||||
// sibling layer's contents. The drawable/queue tail below never runs.
|
||||
return encodeToSurface(
|
||||
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
|
||||
keepAlive: keepAlive, bind: bind)
|
||||
}
|
||||
#endif
|
||||
if let providedDrawable,
|
||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
||||
@@ -963,61 +974,6 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
#if os(macOS)
|
||||
if windowedPresentActive == .transaction {
|
||||
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
|
||||
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
|
||||
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
|
||||
// flip to race WindowServer's swaps). Wait until the GPU work is scheduled (contents
|
||||
// will be ready — p50 ~0.1 ms), then present inside an EXPLICIT CATransaction ON THIS
|
||||
// RENDER THREAD and `flush()`. `presentAtMediaTime` does not apply — the transaction
|
||||
// paces.
|
||||
//
|
||||
// Threading history, because BOTH failure modes shipped or nearly shipped:
|
||||
// • A bare `present()` from this thread (no transaction) never flushes — nothing
|
||||
// commits a runloop-less thread's implicit transaction, so drawables are never
|
||||
// released; after maximumDrawableCount vends `nextDrawable()` blocks forever and
|
||||
// the stream FREEZES (the fullscreen→windowed switch did exactly this).
|
||||
// • The explicit begin/commit alone is NOT enough either: this thread has an ACTIVE
|
||||
// implicit transaction (the layer mutations above — drawableSize/colour — created
|
||||
// it), so the explicit transaction NESTS inside it and its commit defers to the
|
||||
// implicit one that never comes. The harness reproduced the exact freeze: every
|
||||
// present reported presentedTime=0, nothing reached glass. `CATransaction.flush()`
|
||||
// pushes the implicit transaction (present included) to the render server NOW.
|
||||
// • The original fix hopped to MAIN and presented there — correct, but slow in the
|
||||
// field (presents=55 @ fps=240, display_p50 18.6 ms on the 240 Hz Studio): each
|
||||
// present lands a runloop turn late, and main's own implicit transaction batches
|
||||
// enrolled presents at runloop-iteration rate. Kept as PUNKTFUNK_TXN_PRESENT=main.
|
||||
// The off-main commit measured immune to main-thread churn in the harness
|
||||
// (2026-07-21: glass p50 ~10 ms at 240 Hz full-size, cadence a clean 4.17 ms).
|
||||
commandBuffer.commit()
|
||||
let schedStart = CACurrentMediaTime()
|
||||
commandBuffer.waitUntilScheduled()
|
||||
let schedMs = (CACurrentMediaTime() - schedStart) * 1000
|
||||
let commitStart = CACurrentMediaTime()
|
||||
if txnPresentOnMain {
|
||||
let presentedDrawable = drawable
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
presentedDrawable.present()
|
||||
CATransaction.commit()
|
||||
}
|
||||
} else {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
drawable.present()
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
}
|
||||
windowedDiag.record(
|
||||
schedMs: schedMs, commitMs: (CACurrentMediaTime() - commitStart) * 1000,
|
||||
mode: .transaction)
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
||||
// immediate otherwise. A target already in the past presents immediately — same thing.
|
||||
if let presentAtMediaTime {
|
||||
@@ -1025,146 +981,12 @@ public final class MetalVideoPresenter {
|
||||
} else {
|
||||
commandBuffer.present(drawable)
|
||||
}
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED `surface` present tail (see `WindowedPresentMode.surface`): render with the
|
||||
/// same per-frame pipeline into a pooled IOSurface and hand it to `surfaceLayer.contents`
|
||||
/// from the command buffer's COMPLETION handler, inside an explicit CATransaction + flush
|
||||
/// (the same off-main commit discipline as the transactional present — an ordinary
|
||||
/// damaged-layer update on WindowServer's own composite cadence, no image queue anywhere).
|
||||
/// RENDER THREAD. `onPresented` is stamped right after the contents swap commits — the
|
||||
/// closest observable analogue of "reached glass" here (the composite follows within a
|
||||
/// refresh, so the display-stage meters read slightly OPTIMISTIC in this mode).
|
||||
///
|
||||
/// The pool tracks `hdrActive`: bgra8 for SDR, rgba16Float tagged BT.2100 PQ for HDR —
|
||||
/// `configure` already ran, so the caller's `pipeline` attachment format always matches.
|
||||
/// HDR OPEN RISK (why this whole mode is a prototype): whether the compositor honors the
|
||||
/// PQ tag + EDR for plain-CALayer IOSurface contents needs an on-glass eyeball; the metal
|
||||
/// layer underneath keeps `wantsExtendedDynamicRangeContent` as the EDR anchor (the harness
|
||||
/// measured the display's EDR headroom engaging with this arrangement).
|
||||
private func encodeToSurface(
|
||||
targetSize: CGSize, pipeline: MTLRenderPipelineState,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
ensureSurfacePool(size: targetSize, hdr: hdrActive)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let diag = windowedDiag
|
||||
let commitStamp = CACurrentMediaTime()
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // sources pinned until the GPU finished sampling
|
||||
let completedAt = CACurrentMediaTime()
|
||||
// Swap on THIS Metal completion thread: explicit transaction + flush, so the commit
|
||||
// reaches the render server now, independent of main (completion handlers for one
|
||||
// queue fire in execution order, so swaps can't reorder).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
diag.record(
|
||||
schedMs: (completedAt - commitStamp) * 1000,
|
||||
commitMs: (CACurrentMediaTime() - completedAt) * 1000, mode: .surface)
|
||||
onPresented?(Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size`/`hdr` — 4 IOSurface render targets (one on glass, one
|
||||
/// committed in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the
|
||||
/// pool empty; the caller returns false and the ring's putBack + display-link retry take
|
||||
/// over.
|
||||
private func ensureSurfacePool(size: CGSize, hdr: Bool) {
|
||||
guard size != surfacePoolSize || hdr != surfacePoolHDR else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
surfacePoolHDR = hdr
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// rgba16Float (8 B/px) carries the PQ-encoded HDR samples; bgra8 the SDR ones. 256-byte
|
||||
// row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerElement = hdr ? 8 : 4
|
||||
let bytesPerRow = ((w * bytesPerElement) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: bytesPerElement,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: hdr
|
||||
? kCVPixelFormatType_64RGBAHalf : kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: hdr ? .rgba16Float : .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
if hdr, let name = CGColorSpace(name: CGColorSpace.itur_2100_PQ)?.name {
|
||||
// Tag the surface BT.2100 PQ so the compositor interprets the half-float
|
||||
// samples as PQ-encoded HDR (the CALayer-contents analogue of the metal
|
||||
// layer's colorspace).
|
||||
IOSurfaceSetValue(surface, "IOSurfaceColorSpace" as CFString, name)
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
// The EDR request rides the SURFACE layer too (its contents are what composite); the
|
||||
// metal layer underneath keeps its own from configureColor as the anchor. Layer flags
|
||||
// are committed by the next swap's transaction flush.
|
||||
surfaceLayer.wantsExtendedDynamicRangeContent = hdr
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||
private func makeTexture(
|
||||
|
||||
@@ -142,17 +142,20 @@ enum PresentPriority: Equatable {
|
||||
|
||||
final class SessionPresenter {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — for SMOOTHNESS, not as the panic
|
||||
/// fix (that is the windowed transactional present — see `setComposited`). PyroWave's wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump presents within the same
|
||||
/// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the
|
||||
/// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass
|
||||
/// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead
|
||||
/// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt —
|
||||
/// disproven: a fully serialized stream still panicked, which is why the real fix moved to the
|
||||
/// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing —
|
||||
/// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency
|
||||
/// spaces their presents.
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||
/// of stage-2 defaults there predate any panic report.
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
@@ -175,25 +178,10 @@ final class SessionPresenter {
|
||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||
///
|
||||
#if os(macOS)
|
||||
/// Resolve the windowed (composited) present MECHANISM for this session — the DCP
|
||||
/// swapID-panic mitigation picker (see `WindowedPresentMode`). The
|
||||
/// `PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface` env lever wins (dev A/B);
|
||||
/// otherwise the user's safe-present setting: ON/unset → `.transaction` (the validated
|
||||
/// mitigation), OFF → `.async` (the fast pre-mitigation path — the panic returns on
|
||||
/// affected high-refresh setups; the Settings caption says so). `.surface` is currently
|
||||
/// env-only (prototype — HDR-composite verification owed). Fullscreen always presents
|
||||
/// async regardless (`setComposited`). Internal (not private) for unit tests.
|
||||
static func windowedPresentMode(setting: Bool?, env: String?) -> WindowedPresentMode {
|
||||
if let env, let mode = WindowedPresentMode(rawValue: env) { return mode }
|
||||
return (setting ?? true) ? .transaction : .async
|
||||
}
|
||||
#endif
|
||||
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — a deeper gate only builds
|
||||
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
|
||||
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
||||
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
||||
/// serialization is its point. Internal (not private) for unit tests.
|
||||
static func gateDepth(env: String?) -> Int {
|
||||
#if os(macOS)
|
||||
return 1
|
||||
@@ -208,16 +196,10 @@ final class SessionPresenter {
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The windowed present MECHANISM this session runs while composited (resolved once per
|
||||
/// session in `start` — the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT
|
||||
/// dev override) and the routing last pushed to the pipeline — see `setComposited` (the DCP
|
||||
/// swapID-panic mitigation). Main-thread only, like all of this.
|
||||
private var windowedMode: WindowedPresentMode = .transaction
|
||||
private var windowedPresentApplied: WindowedPresentMode = .async
|
||||
/// The windowed `surface` present target (sibling above `metalLayer`, transparent while
|
||||
/// unused) — installed whenever stage-2 runs so a mechanism flip never has to mutate the
|
||||
/// layer tree mid-session.
|
||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||
private var surfaceLayer: CALayer?
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
@@ -301,17 +283,11 @@ final class SessionPresenter {
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
windowedPresentApplied = .async
|
||||
// Resolve THIS session's windowed mechanism once (setting + dev env lever) —
|
||||
// `setComposited` routes between it and fullscreen-async from every layout.
|
||||
windowedMode = Self.windowedPresentMode(
|
||||
setting: UserDefaults.standard.object(
|
||||
forKey: DefaultsKey.windowedSafePresent) as? Bool,
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
|
||||
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
|
||||
// unless the surface mechanism actually presents, covering it while it does.
|
||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||
// contents) while the metal path presents, covering it while surface presents run.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
@@ -456,23 +432,19 @@ final class SessionPresenter {
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed)
|
||||
/// session presents through this session's resolved mitigation mechanism (`windowedMode` —
|
||||
/// transactional by default, see `windowedPresentMode`) instead of the async image queue —
|
||||
/// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the
|
||||
/// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs:
|
||||
/// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 —
|
||||
/// it is the async image queue itself, not any codec or present rate. Fullscreen keeps the
|
||||
/// async path (direct scanout, lowest latency, no panic there). The full HDR/EDR render
|
||||
/// path is preserved in every mechanism.
|
||||
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2 else { return }
|
||||
let mode: WindowedPresentMode = composited ? windowedMode : .async
|
||||
guard mode != windowedPresentApplied else { return }
|
||||
let wasSurface = windowedPresentApplied == .surface
|
||||
windowedPresentApplied = mode
|
||||
stage2.setWindowedPresent(mode)
|
||||
if wasSurface {
|
||||
guard let stage2, let connection else { return }
|
||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||
guard wantsSurface != surfacePresentsActive else { return }
|
||||
surfacePresentsActive = wantsSurface
|
||||
stage2.setSurfacePresents(wantsSurface)
|
||||
if !wantsSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
@@ -499,7 +471,7 @@ final class SessionPresenter {
|
||||
#if os(macOS)
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
windowedPresentApplied = .async
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -1114,15 +1114,15 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Forward the windowed present mechanism (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation).
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
presenter.setWindowedPresent(mode)
|
||||
}
|
||||
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
|
||||
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling
|
||||
/// ABOVE `layer` (transparent while unused — see `MetalVideoPresenter.surfaceLayer`).
|
||||
var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
presenter.setSurfacePresents(on)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
@@ -1213,9 +1213,7 @@ public final class Stage2Pipeline {
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
||||
let receivedNs = au.pulledNs
|
||||
let receivedNs = au.receivedNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
|
||||
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds — the decode stage's start
|
||||
/// point. (Named for its historical role; since the ABI v9 receipt split the true
|
||||
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receipt→pull is the HUD's own
|
||||
/// client-queue term.) 0 when unknown (a caller that didn't stamp) — the decode-stage meter
|
||||
/// then drops the sample via its sanity guard.
|
||||
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
var infoOut = VTDecodeInfoFlags()
|
||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
|
||||
// receipt: both consumers — the decode-stage meter and the ABR decode signal — are
|
||||
// specified from the pull, and the receipt→pull wait is the HUD's separate client-queue
|
||||
// term (see AccessUnit.pulledNs).
|
||||
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
|
||||
@@ -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
|
||||
@@ -38,11 +38,10 @@ private let streamInputDebug =
|
||||
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
||||
/// hide/unhide and associate are balanced via `captured`.
|
||||
///
|
||||
/// In the DESKTOP mouse model (absolute pointer, remote-desktop-sweep M1) this is a no-op:
|
||||
/// the pointer stays free (entering and leaving the stream at will) and StreamLayerView
|
||||
/// forwards ABSOLUTE positions instead; the local cursor is hidden only while over the view
|
||||
/// (cursor rects). `disassociate` selects between the two; `release()` only undoes what
|
||||
/// `capture` actually did.
|
||||
/// In CLIENT-SIDE-CURSOR mode (gamescope, whose capture carries no host cursor) this is a
|
||||
/// no-op: the local cursor stays visible and free, and StreamLayerView forwards ABSOLUTE
|
||||
/// positions instead — the visible system cursor IS the on-screen cursor. `disassociate`
|
||||
/// selects between the two; `release()` only undoes what `capture` actually did.
|
||||
private final class CursorCapture {
|
||||
private var captured = false
|
||||
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
||||
@@ -208,50 +207,14 @@ public final class StreamLayerView: NSView {
|
||||
/// forwarded). Main-thread only.
|
||||
public private(set) var captured = false
|
||||
|
||||
/// Desktop (absolute) mouse model — remote-desktop-sweep M1: when true the pointer is
|
||||
/// never disassociated (it enters and leaves the stream freely) and the mouse monitor
|
||||
/// forwards ABSOLUTE positions through the letterbox; the local cursor is hidden only
|
||||
/// while over this view (cursor rects — the host's composited cursor, tracking our
|
||||
/// sends, is the one you see) and reappears the moment it leaves. When false the
|
||||
/// captured/disassociated relative path runs unchanged. Initialized at session start
|
||||
/// from the `mouseMode` setting gated by the host's resolved compositor (gamescope's
|
||||
/// EIS is relative-only — absolute sends would be dropped, so it pins to capture);
|
||||
/// 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
|
||||
/// Client-side-cursor mode: when true the local system cursor stays VISIBLE over the
|
||||
/// stream and the mouse monitor forwards ABSOLUTE positions (the visible cursor is the
|
||||
/// on-screen cursor — gamescope draws none, so no double cursor); when false the existing
|
||||
/// captured/disassociated relative path runs unchanged. Initialized at session start from
|
||||
/// the `cursorMode` setting + the host's resolved compositor, toggled live by ⌘⇧C. A live
|
||||
/// flip re-engages capture in the new mode so disassociation + the abs/rel choice swap
|
||||
/// atomically. Main-thread only.
|
||||
private var cursorVisible = 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).
|
||||
@@ -477,9 +440,9 @@ public final class StreamLayerView: NSView {
|
||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
||||
// In client-side-cursor mode there is no grab (the cursor stays visible) — capture
|
||||
// always engages and the monitor forwards absolute positions instead.
|
||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
||||
guard cursorCapture.capture(in: self, disassociate: !cursorVisible) else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||
@@ -487,9 +450,7 @@ public final class StreamLayerView: NSView {
|
||||
installMouseMonitor()
|
||||
captured = true
|
||||
window?.makeFirstResponder(self)
|
||||
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
|
||||
notifyCaptureChange(true)
|
||||
reconcileCursorRender()
|
||||
}
|
||||
|
||||
private func releaseCapture() {
|
||||
@@ -498,205 +459,7 @@ public final class StreamLayerView: NSView {
|
||||
cursorCapture.release()
|
||||
inputCapture?.setForwarding(false)
|
||||
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 —
|
||||
/// an empty 1×1 image draws nothing.
|
||||
private static let invisibleCursor = NSCursor(
|
||||
image: NSImage(size: NSSize(width: 1, height: 1)), hotSpot: .zero)
|
||||
|
||||
/// Desktop mouse model: the local cursor is hidden while over the stream (the host's
|
||||
/// composited cursor, tracking our absolute sends, is the one you see) and reappears
|
||||
/// the moment it leaves the view — AppKit applies/removes the rect's cursor for us,
|
||||
/// so there is no hide/unhide balancing to get wrong. Capture model instead hides
|
||||
/// 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)
|
||||
}
|
||||
} 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
|
||||
@@ -710,12 +473,12 @@ public final class StreamLayerView: NSView {
|
||||
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
||||
/// inert locally.
|
||||
///
|
||||
/// In the desktop mouse model the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||
/// In client-side-cursor mode the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
||||
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
||||
private func installMouseMonitor() {
|
||||
guard mouseEventMonitor == nil else { return }
|
||||
if desktopMouse {
|
||||
if cursorVisible {
|
||||
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
||||
window?.acceptsMouseMovedEvents = true
|
||||
}
|
||||
@@ -727,8 +490,8 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
||||
switch event.type {
|
||||
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
if self.desktopMouse {
|
||||
// Desktop mouse model: forward the ABSOLUTE position (mapped through the
|
||||
if self.cursorVisible {
|
||||
// Client-side cursor: forward the ABSOLUTE position (mapped through the
|
||||
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
||||
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
||||
if let p = self.hostPoint(from: event) {
|
||||
@@ -846,25 +609,14 @@ public final class StreamLayerView: NSView {
|
||||
// be a cursor trap with dead input.
|
||||
self?.releaseCapture()
|
||||
}
|
||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop) live — the SDL clients' identical
|
||||
// chord. Only the key window's stream owns it (same guard as the ⌘⎋ capture toggle).
|
||||
// Re-engage capture in the new model so disassociation and the absolute/relative
|
||||
// forwarding choice swap atomically — releaseCapture restores the old model's grab
|
||||
// (if any), engageCapture installs the new one. On a gamescope host the chord is a
|
||||
// no-op: its EIS grants only a relative pointer, so the desktop model's absolute
|
||||
// sends would be silently dropped (pointer stuck = "all input dead").
|
||||
capture.onToggleMouseMode = { [weak self] in
|
||||
guard let self, self.window?.isKeyWindow == true,
|
||||
let conn = self.connection else { return }
|
||||
guard conn.resolvedCompositor != .gamescope else {
|
||||
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)")
|
||||
}
|
||||
// ⌘⇧C flips the client-side cursor live. Only the key window's stream owns it (same
|
||||
// guard as the ⌘⎋ capture toggle). Re-engage capture in the new mode so disassociation
|
||||
// and the absolute/relative forwarding choice swap atomically — releaseCapture restores
|
||||
// the old mode's grab (if any), engageCapture installs the new one.
|
||||
// ⌘⇧C would flip the client-side cursor live — NEUTERED while the feature is disabled
|
||||
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
||||
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
||||
capture.onToggleCursor = {}
|
||||
// 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.
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
@@ -891,26 +643,15 @@ public final class StreamLayerView: NSView {
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
|
||||
// Desktop (absolute) mouse model — resolved at session start from the mouseMode
|
||||
// setting, gated by the host's compositor: gamescope's input socket (EIS) grants
|
||||
// only a relative pointer, so absolute sends would be silently dropped there
|
||||
// (pointer stuck = "all input dead") — pinned to capture. ⌃⌥⇧M flips it live.
|
||||
let mode = MouseInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? ""
|
||||
) ?? .capture
|
||||
let absOK = connection.resolvedCompositor != .gamescope
|
||||
desktopMouse = mode == .desktop && absOK
|
||||
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)
|
||||
}
|
||||
// Client-side cursor is TEMPORARILY DISABLED. It positions the host cursor with ABSOLUTE
|
||||
// events, but gamescope's input socket (EIS) grants only a relative pointer, so those are
|
||||
// silently dropped — the pointer never moves and clicks/scroll land on the stuck position
|
||||
// (looks like "all input dead"). gamescope is exactly the compositor Auto enabled it for.
|
||||
// Forced off until per-compositor gating (KWin/GNOME/Sway have absolute) or a synthetic-
|
||||
// cursor-over-relative path lands; the resolution logic below is kept for that. See the
|
||||
// ⌘⇧C handler (also neutered) and the cursorMode setting (hidden).
|
||||
cursorVisible = false
|
||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
@@ -959,9 +700,9 @@ public final class StreamLayerView: NSView {
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||
// re-layout, so this stays current): a windowed session presents through a Core Animation
|
||||
// transaction — the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited).
|
||||
// A view not yet in a window counts as composited (the safe default).
|
||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||
// not yet in a window counts as composited (the safe default).
|
||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
@@ -970,11 +711,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 +741,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) }
|
||||
|
||||
@@ -70,16 +70,6 @@ public enum DefaultsKey {
|
||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
||||
public static let vsync = "punktfunk.vsync"
|
||||
/// macOS: present WINDOWED sessions in lockstep with the system compositor (the DCP
|
||||
/// "mismatched swapID's" kernel-panic mitigation — see SessionPresenter.windowedPresentMode
|
||||
/// and the MetalVideoPresenter saga notes). ON/unset (the default): windowed presents ride
|
||||
/// a Core Animation transaction — validated panic-free on the 240 Hz repro machine, at a
|
||||
/// small display-latency cost vs the raw path. OFF: windowed sessions keep the fast async
|
||||
/// image queue — ON AFFECTED SETUPS (high-refresh displays) THAT PATH KERNEL-PANICS THE
|
||||
/// WHOLE MAC, which is why the default is ON. Fullscreen always presents async (fast path)
|
||||
/// regardless. Resolved once per session; PUNKTFUNK_WINDOWED_PRESENT=async|transaction|
|
||||
/// surface overrides it for dev A/B.
|
||||
public static let windowedSafePresent = "punktfunk.windowedSafePresent"
|
||||
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
||||
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
||||
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
||||
@@ -94,11 +84,8 @@ public enum DefaultsKey {
|
||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||
public static let enable444 = "punktfunk.enable444"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or
|
||||
/// "desktop" (uncaptured absolute pointer) — the cross-client `mouse_mode`. Replaces the
|
||||
/// never-shipped "punktfunk.cursorMode" (auto/always/never client-side-cursor setting,
|
||||
/// which was hidden while disabled and had no readers).
|
||||
public static let mouseMode = "punktfunk.mouseMode"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -316,41 +316,6 @@ final class PresentPacingTests: XCTestCase {
|
||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
||||
}
|
||||
|
||||
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
|
||||
|
||||
#if os(macOS)
|
||||
/// The safe-present setting: ON/unset → the validated transactional mitigation; an explicit
|
||||
/// OFF → the fast async path (the user accepted the affected-setup panic risk). The
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
|
||||
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
|
||||
func testWindowedPresentModeResolution() {
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
|
||||
"unset defaults to the panic mitigation")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
|
||||
"an explicit opt-out gets the fast async path")
|
||||
// The dev env lever wins over the setting, both directions.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
|
||||
.transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
|
||||
"the surface prototype is reachable via env only")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
|
||||
// Garbage/empty env = unset.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Glass-gate depth
|
||||
|
||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
#
|
||||
@@ -63,17 +61,10 @@ if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||
set -- --fullscreen
|
||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+10
-87
@@ -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 {
|
||||
@@ -93,9 +70,7 @@ function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||
};
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||
// v3: CI zips through 0.17.1 shipped no assets/ at all, yet v2 was still recorded as applied
|
||||
// on those installs — the bump makes them re-apply once on the first build that has the files.
|
||||
const ART_VERSION = 3;
|
||||
const ART_VERSION = 2;
|
||||
function artKey(appId: number): string {
|
||||
return `punktfunk:shortcutArt:${appId}`;
|
||||
}
|
||||
@@ -104,7 +79,7 @@ function artKey(appId: number): string {
|
||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||
*/
|
||||
async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
async function applyArtwork(appId: number): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||
return;
|
||||
@@ -116,29 +91,16 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
[art.logo, 2],
|
||||
[art.gridwide, 3],
|
||||
];
|
||||
let applied = false;
|
||||
for (const [data, assetType] of assets) {
|
||||
if (data) {
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
if (art.icon_path) {
|
||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||
applied = true;
|
||||
}
|
||||
// Only record "done" when something actually landed — a plugin build whose assets/ is
|
||||
// missing/empty must keep retrying on later mounts instead of poisoning the marker.
|
||||
if (applied) {
|
||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||
}
|
||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||
} catch (e) {
|
||||
// A shortcut fresh out of AddShortcut may not be registered yet (the same race
|
||||
// setShortcutHidden defers around) — one deferred second attempt, then leave it to
|
||||
// the next mount.
|
||||
if (!isRetry) {
|
||||
setTimeout(() => void applyArtwork(appId, true), 2500);
|
||||
}
|
||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||
}
|
||||
}
|
||||
@@ -195,9 +157,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||
// `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend
|
||||
// succeeds without pointing any account at the template — keep retrying until one lands.
|
||||
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
|
||||
if (r?.ok) {
|
||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||
} else {
|
||||
console.warn("punktfunk: controller config not fully applied", r);
|
||||
@@ -222,10 +182,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 +219,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 +239,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();
|
||||
@@ -352,21 +283,13 @@ export async function launchStream(
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const { appId, runner } = await ensureStreamShortcut();
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||
if (woke.ok) {
|
||||
env.push("PF_CONNECT_TIMEOUT=75");
|
||||
}
|
||||
if (opts.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
@@ -380,9 +303,9 @@ export async function launchStream(
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
}
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||
// awaited above, so the magic packet is out before the connect attempt.
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
await waking; // ensure the magic packet is out before the connect attempt
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,14 +69,6 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"];
|
||||
const MOUSE_MODE_CAPTIONS: &[&str] = &[
|
||||
"Pointer locks to the stream — relative motion, best for games",
|
||||
"Pointer moves freely in and out — best for remote desktop work",
|
||||
];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -550,20 +542,6 @@ pub fn show(
|
||||
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
||||
});
|
||||
}
|
||||
let mouse_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Mouse input",
|
||||
MOUSE_MODE_CAPTIONS[0],
|
||||
MOUSE_MODE_LABELS,
|
||||
);
|
||||
{
|
||||
let w = mouse_row.widget().clone();
|
||||
mouse_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]);
|
||||
});
|
||||
}
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
@@ -740,12 +718,6 @@ pub fn show(
|
||||
touch_row.set_selected(touch_i as u32);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
||||
let mouse_i = MOUSE_MODES
|
||||
.iter()
|
||||
.position(|&m| m == s.mouse_mode)
|
||||
.unwrap_or(0);
|
||||
mouse_row.set_selected(mouse_i as u32);
|
||||
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]);
|
||||
let comp_i = COMPOSITORS
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -816,7 +788,6 @@ pub fn show(
|
||||
touch_group.add(touch_row.widget());
|
||||
// Group titles are Pango markup — the ampersand must be an entity.
|
||||
let kbm_group = group("Keyboard & mouse", "");
|
||||
kbm_group.add(mouse_row.widget());
|
||||
kbm_group.add(&inhibit_row);
|
||||
kbm_group.add(&invert_row);
|
||||
input.add(&touch_group);
|
||||
@@ -885,19 +856,9 @@ pub fn show(
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
||||
// writing that back would silently erase it just by opening + closing the dialog.
|
||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
||||
// a listed one to begin with.
|
||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
|
||||
+13
-29
@@ -458,11 +458,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
),
|
||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||
}
|
||||
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
||||
// Frame every read on the control stream through the resumable reader, exactly as the client
|
||||
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
||||
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
||||
let mut recv = io::MsgReader::new(recv);
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
@@ -487,24 +483,14 @@ async fn session(args: Args) -> Result<()> {
|
||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
|
||||
// blocks, and the probe is exactly the tool that measures the overlap win.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
|
||||
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||
}
|
||||
// PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the
|
||||
// host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7
|
||||
// negotiation, design/chacha20-session-cipher.md §7) without a webOS build;
|
||||
// the negotiated cipher is reported in the welcome log line below.
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20;
|
||||
}
|
||||
caps
|
||||
},
|
||||
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||
@@ -523,15 +509,12 @@ 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(),
|
||||
)
|
||||
.await?;
|
||||
let welcome =
|
||||
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
||||
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
tracing::info!(
|
||||
mode = ?welcome.mode,
|
||||
fec = ?welcome.fec,
|
||||
@@ -545,11 +528,6 @@ async fn session(args: Args) -> Result<()> {
|
||||
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||
chroma_format_idc = welcome.chroma_format,
|
||||
codec = codec_ext(welcome.codec),
|
||||
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
|
||||
"chacha20-poly1305"
|
||||
} else {
|
||||
"aes-128-gcm"
|
||||
},
|
||||
"session offer"
|
||||
);
|
||||
|
||||
@@ -651,7 +629,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("Reconfigure write failed");
|
||||
return;
|
||||
}
|
||||
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| Reconfigured::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) if ack.accepted => {
|
||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||
}
|
||||
@@ -704,7 +685,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("SetBitrate write failed");
|
||||
return;
|
||||
}
|
||||
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| BitrateChanged::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) => tracing::info!(
|
||||
applied_kbps = ack.bitrate_kbps,
|
||||
"BITRATE CHANGE acked by host"
|
||||
@@ -766,7 +750,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("ProbeRequest write failed");
|
||||
return;
|
||||
}
|
||||
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
||||
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||
Ok(Ok(r)) => r,
|
||||
other => {
|
||||
tracing::error!(?other, "bad ProbeResult");
|
||||
|
||||
@@ -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) {
|
||||
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
@@ -251,6 +250,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),
|
||||
);
|
||||
@@ -500,7 +429,6 @@ mod session_main {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
|
||||
@@ -90,13 +90,6 @@ const TOUCH_MODES: &[(&str, &str)] = &[
|
||||
("pointer", "Direct pointer"),
|
||||
("touch", "Touch passthrough"),
|
||||
];
|
||||
/// Physical-mouse presets: `(stored value, display label)` — capture (pointer lock,
|
||||
/// relative, for games) vs desktop (uncaptured absolute pointer, for remote desktop
|
||||
/// work). Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -401,10 +394,6 @@ pub(crate) fn settings_page(
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
@@ -553,13 +542,6 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Keyboard & mouse"),
|
||||
vec![
|
||||
described(
|
||||
mouse_combo,
|
||||
"Capture locks the pointer to the stream and sends relative motion — \
|
||||
best for games. Desktop leaves the pointer free to enter and leave \
|
||||
the stream and sends absolute positions — best for remote desktop \
|
||||
work. Ctrl+Alt+Shift+M switches live.",
|
||||
),
|
||||
described(
|
||||
shortcuts_toggle,
|
||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||
|
||||
@@ -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
-177
@@ -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,28 +19,11 @@ 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
|
||||
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
|
||||
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
|
||||
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
|
||||
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
|
||||
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
|
||||
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
|
||||
self.next_frame()
|
||||
}
|
||||
|
||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||
@@ -61,77 +42,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 +73,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 +87,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 →
|
||||
@@ -352,35 +249,12 @@ pub struct ZeroCopyPolicy {
|
||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
||||
pub pyrowave_session: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
||||
pub native_nv12_session: bool,
|
||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
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 +351,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 +383,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 +408,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 +417,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 +434,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 +443,6 @@ pub fn open_idd_push(
|
||||
pyrowave,
|
||||
keepalive,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
+2116
-469
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!(
|
||||
|
||||
@@ -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),
|
||||
@@ -459,16 +447,8 @@ fn pump(
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
match connector.next_frame(Duration::from_millis(20)) {
|
||||
Ok(frame) => {
|
||||
// The `received` point: reassembly COMPLETION, stamped by the core session as
|
||||
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead
|
||||
// would fold the pre-decode queue wait into `host+network` — a client-side
|
||||
// standing backlog masquerading as network latency (the 2026-07 two-pair
|
||||
// investigation). 0 = a core predating the stamp; fall back to the pull instant.
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns
|
||||
} else {
|
||||
now_ns()
|
||||
};
|
||||
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||
let received_ns = now_ns();
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,48 +456,6 @@ impl TouchMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a physical mouse drives the host — the desktop-sweep mouse model
|
||||
/// (design/remote-desktop-sweep.md M1). Stored stringly in [`Settings::mouse_mode`] so the
|
||||
/// file stays readable; parsed with [`MouseMode::from_name`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum MouseMode {
|
||||
/// Pointer lock (relative deltas, hidden cursor) — the game model, and the default:
|
||||
/// the only cursor you see is the host's.
|
||||
Capture,
|
||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
||||
/// motion goes on the wire as absolute positions through the letterbox. The remote
|
||||
/// desktop model. Requires a host injector with absolute support (not gamescope).
|
||||
Desktop,
|
||||
}
|
||||
|
||||
impl MouseMode {
|
||||
/// Cycle/picker order (also the settings pickers' option order).
|
||||
pub const ALL: [MouseMode; 2] = [MouseMode::Capture, MouseMode::Desktop];
|
||||
|
||||
/// Parse the persisted name, defaulting to `Capture` for unset/unknown values.
|
||||
pub fn from_name(s: &str) -> MouseMode {
|
||||
match s {
|
||||
"desktop" => MouseMode::Desktop,
|
||||
_ => MouseMode::Capture,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
||||
pub fn as_name(self) -> &'static str {
|
||||
match self {
|
||||
MouseMode::Capture => "capture",
|
||||
MouseMode::Desktop => "desktop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MouseMode::Capture => "Capture (games)",
|
||||
MouseMode::Desktop => "Desktop (absolute)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -571,12 +490,6 @@ pub struct Settings {
|
||||
/// stores load as trackpad.
|
||||
#[serde(default = "default_touch_mode")]
|
||||
pub touch_mode: String,
|
||||
/// How a physical mouse drives the host: a [`MouseMode`] name — `"capture"` (default,
|
||||
/// pointer lock + relative) or `"desktop"` (uncaptured absolute pointer). Read at
|
||||
/// connect via [`Settings::mouse_mode`]. `default` so pre-existing stores load as
|
||||
/// capture — today's behavior.
|
||||
#[serde(default = "default_mouse_mode")]
|
||||
pub mouse_mode: String,
|
||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
@@ -664,10 +577,6 @@ fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
|
||||
fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -695,10 +604,6 @@ impl Settings {
|
||||
TouchMode::from_name(&self.touch_mode)
|
||||
}
|
||||
|
||||
pub fn mouse_mode(&self) -> MouseMode {
|
||||
MouseMode::from_name(&self.mouse_mode)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -726,7 +631,6 @@ impl Default for Settings {
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
mouse_mode: "capture".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
@@ -29,11 +29,10 @@ enum RowId {
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
Mouse,
|
||||
Stats,
|
||||
}
|
||||
|
||||
const ROWS: [RowId; 14] = [
|
||||
const ROWS: [RowId; 13] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::Bitrate,
|
||||
@@ -46,7 +45,6 @@ const ROWS: [RowId; 14] = [
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::Stats,
|
||||
];
|
||||
|
||||
@@ -253,7 +251,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
"Touch mode",
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
@@ -295,11 +292,6 @@ fn detail(id: RowId) -> &'static str {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
}
|
||||
RowId::Mouse => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -375,11 +367,6 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::Mouse => {
|
||||
let cur = MouseMode::ALL.iter().position(|m| *m == s.mouse_mode());
|
||||
step_option(cur, MouseMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::Stats => {
|
||||
let cur = StatsVerbosity::ALL
|
||||
.iter()
|
||||
@@ -523,33 +510,6 @@ mod tests {
|
||||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.mouse_mode, "capture");
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Capture → Desktop, then a step past the end is a boundary.
|
||||
assert!(
|
||||
!adjust(RowId::Mouse, -1, false, &mut ctx),
|
||||
"already first = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Mouse, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.mouse_mode, "desktop");
|
||||
assert!(!adjust(RowId::Mouse, 1, false, &mut ctx), "last = thud");
|
||||
// A wraps back to the first.
|
||||
assert!(adjust(RowId::Mouse, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -25,11 +25,6 @@ tracing = "0.1"
|
||||
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dev-dependencies]
|
||||
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
|
||||
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
|
||||
pf-capture = { path = "../pf-capture" }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||
openh264 = "0.9"
|
||||
|
||||
@@ -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
|
||||
@@ -71,48 +32,8 @@ pub struct EncodedFrame {
|
||||
pub chunk_aligned: bool,
|
||||
}
|
||||
|
||||
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
|
||||
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
|
||||
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
|
||||
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
|
||||
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
|
||||
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
|
||||
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
|
||||
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
|
||||
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
|
||||
/// driver's reported picture type.
|
||||
pub struct AuChunk {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
pub keyframe: bool,
|
||||
/// See [`EncodedFrame::recovery_anchor`].
|
||||
pub recovery_anchor: bool,
|
||||
/// See [`EncodedFrame::chunk_aligned`].
|
||||
pub chunk_aligned: bool,
|
||||
/// Opens the AU (carries the authoritative AU metadata).
|
||||
pub first: bool,
|
||||
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
|
||||
pub last: bool,
|
||||
}
|
||||
|
||||
impl AuChunk {
|
||||
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
|
||||
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
|
||||
pub fn whole(f: EncodedFrame) -> Self {
|
||||
AuChunk {
|
||||
data: f.data,
|
||||
pts_ns: f.pts_ns,
|
||||
keyframe: f.keyframe,
|
||||
recovery_anchor: f.recovery_anchor,
|
||||
chunk_aligned: f.chunk_aligned,
|
||||
first: true,
|
||||
last: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +170,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 +182,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,31 +215,10 @@ 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.
|
||||
pub trait Encoder: Send {
|
||||
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
|
||||
/// (and its GPU payload) alive until this frame's AU has been returned by
|
||||
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
|
||||
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
|
||||
/// loops already hold the frame across their poll drain; new callers must do the same.
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||
@@ -339,10 +236,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 +248,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
|
||||
@@ -368,44 +264,8 @@ pub trait Encoder: Send {
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
||||
/// 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.
|
||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
|
||||
/// session has slice-level readback armed (Linux direct-NVENC with the
|
||||
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
|
||||
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
|
||||
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
|
||||
/// `poll_chunk` degrades to one whole-AU chunk per frame.
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
|
||||
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
|
||||
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
|
||||
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
|
||||
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
|
||||
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
|
||||
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
|
||||
/// whole AU as a single `first && last` chunk.
|
||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
||||
Ok(self.poll()?.map(AuChunk::whole))
|
||||
}
|
||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||
@@ -426,16 +286,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
|
||||
@@ -443,32 +293,8 @@ pub trait Encoder: Send {
|
||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||
/// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts
|
||||
/// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's
|
||||
/// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer
|
||||
/// rotates its output ring per delivered frame with no regard for encode completion, so a
|
||||
/// deeper pipeline lets it overwrite a texture mid-encode. That is visual corruption (torn or
|
||||
/// mixed frames), not UB, so it fails silently and intermittently.
|
||||
///
|
||||
/// Called once by the session glue after the capturer is known; a backend that copies its
|
||||
/// input, or is synchronous, ignores it. Default: no-op.
|
||||
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 +329,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 +342,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 +370,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 +377,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 +393,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] {
|
||||
@@ -691,23 +412,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
|
||||
/// metadata verbatim and be self-closing (`first && last`).
|
||||
#[test]
|
||||
fn whole_au_chunk_is_self_closing() {
|
||||
let c = AuChunk::whole(EncodedFrame {
|
||||
data: vec![0, 0, 0, 1, 0x40],
|
||||
pts_ns: 42,
|
||||
keyframe: true,
|
||||
recovery_anchor: true,
|
||||
chunk_aligned: false,
|
||||
});
|
||||
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
|
||||
assert_eq!(c.pts_ns, 42);
|
||||
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
|
||||
assert!(c.first && c.last);
|
||||
}
|
||||
|
||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||
#[test]
|
||||
fn codec_wire_roundtrip_and_label() {
|
||||
|
||||
@@ -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]);
|
||||
@@ -877,7 +826,7 @@ impl NvencEncoder {
|
||||
(*f).linesize[i] as usize,
|
||||
)
|
||||
});
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
||||
} else if self.want_444 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!(
|
||||
@@ -890,11 +839,11 @@ impl NvencEncoder {
|
||||
let y_pitch = (*f).linesize[0] as usize;
|
||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f).linesize[1] as usize;
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
||||
} else {
|
||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f).linesize[0] as usize;
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
||||
};
|
||||
if let Err(e) = copy_res {
|
||||
ffi::av_frame_free(&mut f);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user