Compare commits

..

2 Commits

Author SHA1 Message Date
enricobuehler 7cde80ce84 fix(encode): NVENC partial-init session leak + three backend-parity gaps
All four found in the pf-encode quality sweep and verified against source.

- NVENC partial-init leak (BOTH platforms, high): `init_session` publishes
  `self.encoder` — and on Windows charges LIVE_SESSION_UNITS — *before* its
  remaining fallible steps (bitstream buffers; on Linux also the input-surface
  alloc and `register_resource`). A failure there left a live session with
  `inited == false`, and every guard on the re-init path keys off `inited`, so
  the next submit skipped teardown and overwrote `self.encoder`: the session
  leaked permanently toward the driver's per-process cap, and its budget units
  never returned, progressively starving parallel-display admission. `teardown`
  already keys off `encoder.is_null()` rather than `inited`, so it cleans up
  exactly this half-built state — it just was never called. Now invoked on the
  `init_session` error path on both platforms.

- `can_encode_10bit` asked the wrong backend (medium): it resolved via
  `linux_auto_is_vaapi`, which ignores `encoder_pref`, while `can_encode_444`
  and `open_video` honour it. On a host that forces a backend (e.g.
  `encoder_pref = "vaapi"` on an NVIDIA box) the probe answered for NVENC while
  the session opened VAAPI, so the negotiated bit depth — and the HDR/SDR colour
  label derived from it — described a backend that never ran. Now uses the same
  `linux_zero_copy_is_vaapi` mirror, and `linux_auto_is_vaapi` carries a warning
  that it resolves the `auto` case only and is not a dispatch mirror.

- Linux software arm ignored SW_BITRATE_CEIL (low): the Windows arm clamped
  openh264 to 100 Mbps, the Linux arm passed the full negotiated rate. The
  constant is now module-scope so both arms share one value.

- QSV/AMF env-parity (low): `PUNKTFUNK_IR_PERIOD_FRAMES` was a no-op on QSV
  despite the comment claiming parity with AMF, and `PUNKTFUNK_NO_QSV_LTR` /
  `PUNKTFUNK_INTRA_REFRESH` had dropped AMF's `trim()` and `yes`/`on` spellings,
  so a value with stray whitespace silently did nothing on Intel while the same
  value worked on AMD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:43:16 +02:00
enricobuehler ab679a56e7 chore(release): bump workspace version to 0.15.0
MINOR, not patch: 44 commits since v0.14.0 split 21 features / 19 fixes, and
/api/v1 gained the per-scanner library-toggle endpoints (additive, already
regenerated into api/openapi.json by c2bba134). sdk-publish pushes this version
to consumers, so a patch bump would understate a new API surface to anyone
pinning ~0.14.

Headline work is the desktop clients drawing level with the Apple revamp: the
Windows client picked up settings parity, a findable console UI in the header,
the shared clipboard (with a per-host toggle), PyroWave decode in the codec
picker, and D3D11VA-first decode + HDR pass-through on Intel; the Linux GTK4
client got the same category-map settings rebuild. Apple landed the intent-based
presenter rebuild and the Dynamic Island redesign. Also: the punktfunk-host
plugins CLI, per-scanner library toggles in the console, and PyroWave raw-dmabuf
zero-copy capture on the Linux NVIDIA host.

Notable fixes: LTR-RFI loss recovery under sustained loss, two encode teardown
memory-safety holes, the audio first-open retry that was leaving sessions
silent, and the GameStream stream-marker announcement on the compat plane.

Every workspace crate is on version.workspace = true, so this stayed a one-line
bump plus the lock sync. (fec-rs, pf-driver-proto, usbip-sim, the Windows driver
crates and pf-vkhdr-layer are deliberately versioned independently and stay put.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:32:43 +02:00
225 changed files with 4696 additions and 25280 deletions
+30 -24
View File
@@ -6,12 +6,17 @@
# #
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT # 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). # 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 # We build the frontend with pnpm and assemble the store-layout zip by hand:
# 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 # punktfunk.zip
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin # punktfunk/ <- single top-level dir == plugin.json "name"
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on # plugin.json [required]
# top: the {channel, manifest} pointer the plugin's self-update check polls. # 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 # 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 # `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
@@ -85,27 +90,28 @@ jobs:
- name: Assemble store-layout zip - name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
run: | run: |
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
# so an image change can't silently break the build. STAGE="$RUNNER_TEMP/decky"
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null DEST="$STAGE/$PLUGIN"
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/, rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
# LICENSE, …) with the same script local/sideload builds use — see the header comment. cp clients/decky/plugin.json "$DEST/"
# Runs AFTER the version stamp, so the staged package.json carries $VERSION. cp clients/decky/package.json "$DEST/"
bash clients/decky/scripts/package.sh cp clients/decky/main.py "$DEST/"
DEST="clients/decky/out/$PLUGIN" cp clients/decky/dist/index.js "$DEST/dist/"
# CI-only addition: the self-update channel pointer the backend reads (main.py cp clients/decky/README.md "$DEST/"
# check_update). It points at THIS channel's manifest.json (published below); that # The stream-launch wrapper (target of the Steam shortcut); keep it executable
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid # (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
# across future alias re-uploads. 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" 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" ls -lh "$RUNNER_TEMP/punktfunk.zip"
unzip -l "$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 # The update manifest the plugin polls: the immutable per-version artifact + its
# sha256 (Decky's installer verifies the download against this hash, aborting on # 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). # mismatch — so it MUST be the per-version URL, never the mutable alias).
+2 -28
View File
@@ -73,34 +73,8 @@ jobs:
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM # 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 # trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call. # there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve, resolve over TCP) - name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
run: | run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Resolve over TCP instead of UDP. The documented root cause of the flathub
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
# each needing a manual re-run.
#
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
# silently lost under load. Same resolver, same search path — only the transport
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
# NO extra nameservers, which would risk answering an internal name from a public
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
# retry.sh stays as the backstop for genuine upstream blips.
#
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
# DNS tuning that cannot be applied must not be what fails the release build — that
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
# today's behaviour, which retry.sh already covers.
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
fi
cat /etc/resolv.conf || true
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step # 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. # executes via the container shell (no node needed), so install node BEFORE checkout.
-69
View File
@@ -1,69 +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
- 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
-24
View File
@@ -149,26 +149,6 @@ jobs:
# inherits this from the env during the xcframework build). # inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" 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) - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on # 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 # 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 \ -project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \ -destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_ALLOWED=NO
@@ -294,7 +273,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk \ -project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \ -destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \ -allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
@@ -358,7 +336,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk-iOS \ -project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \ -allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
@@ -417,7 +394,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk-tvOS \ -project "$PROJECT" -scheme Punktfunk-tvOS \
-destination 'generic/platform=tvOS' \ -destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \ -allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
Generated
+27 -67
View File
@@ -656,30 +656,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "ciborium" name = "ciborium"
version = "0.2.2" version = "0.2.2"
@@ -715,7 +691,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common",
"inout", "inout",
"zeroize",
] ]
[[package]] [[package]]
@@ -2184,7 +2159,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.17.2" version = "0.15.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2289,7 +2264,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2324,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2813,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2833,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2857,7 +2832,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2875,7 +2850,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2896,7 +2871,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2906,7 +2881,6 @@ dependencies = [
"libvpl-sys", "libvpl-sys",
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
"pf-capture",
"pf-frame", "pf-frame",
"pf-gpu", "pf-gpu",
"pf-host-config", "pf-host-config",
@@ -2920,7 +2894,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2929,7 +2903,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2941,7 +2915,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2955,11 +2929,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.17.2" version = "0.15.0"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2987,14 +2961,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3009,7 +2983,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3039,7 +3013,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3051,7 +3025,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3162,17 +3136,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -3258,7 +3221,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3274,7 +3237,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3290,7 +3253,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3305,7 +3268,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3324,12 +3287,11 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
"cbindgen", "cbindgen",
"chacha20poly1305",
"criterion", "criterion",
"fec-rs", "fec-rs",
"hmac", "hmac",
@@ -3356,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3401,13 +3363,11 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"rcgen", "rcgen",
"reis", "reis",
"ring",
"roxmltree", "roxmltree",
"rsa", "rsa",
"rusqlite", "rusqlite",
"rustls", "rustls",
"rusty_enet", "rusty_enet",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -3440,7 +3400,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3454,7 +3414,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3477,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.17.2" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.17.2" version = "0.15.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1 -1133
View File
File diff suppressed because it is too large Load Diff
@@ -404,14 +404,7 @@ fn feeder_loop(
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The // 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. // HUD-only `received` point + host/network split stay gated on the overlay.
if stats.enabled() || measure_decode { if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping let received_ns = now_realtime_ns();
// 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 mut g = in_flight let mut g = in_flight
.lock() .lock()
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
// samplers (`received` point, host/network split) stay gated on the overlay so // 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. // the hidden steady state adds only a wall-clock read + the receipt push.
if stats.enabled() || measure_decode { if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), not the pull instant — see let received_ns = now_realtime_ns();
// 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()
};
in_flight.push_back((frame.pts_ns / 1000, received_ns)); in_flight.push_back((frame.pts_ns / 1000, received_ns));
if in_flight.len() > IN_FLIGHT_CAP { if in_flight.len() > IN_FLIGHT_CAP {
in_flight.pop_front(); // stale — codec never echoed it back in_flight.pop_front(); // stale — codec never echoed it back
@@ -579,21 +579,13 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo model?.disconnect() // the captured-state D combo
}, },
onFrame: { [meter = model.meter, latency = model.latency, onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue, split = model.latencySplit, offset = conn.clockOffsetNs] au in
offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count) meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset) latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the // The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is // host/network split drained by the 1 s stats tick).
// the core's reassembly stamp (ABI v9), so the split's network term no
// longer contains the client-queue wait...
split.recordReceipt( split.recordReceipt(
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset) ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
// ...which is measured as its own term instead (receiptpull, both
// client-local).
queue.record(
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
offsetNs: 0)
}, },
onSessionEnd: { [weak model] in onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() } Task { @MainActor in model?.sessionEnded() }
@@ -102,12 +102,6 @@ final class SessionModel: ObservableObject {
@Published var decodeValid = false @Published var decodeValid = false
@Published var displayP50Ms = 0.0 @Published var displayP50Ms = 0.0
@Published var displayValid = false @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 /// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
/// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh /// engine's vendglass 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 /// 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 endToEnd = LatencyMeter()
let decodeStage = LatencyMeter() let decodeStage = LatencyMeter()
let displayStage = 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 /// 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. /// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter() let presentFloor = LatencyMeter()
@@ -498,7 +489,6 @@ final class SessionModel: ObservableObject {
endToEndValid = false endToEndValid = false
decodeValid = false decodeValid = false
displayValid = false displayValid = false
clientQueueValid = false
osFloorValid = false osFloorValid = false
lostFrames = 0 lostFrames = 0
lostPct = 0 lostPct = 0
@@ -689,12 +679,6 @@ final class SessionModel: ObservableObject {
} else { } else {
self.osFloorValid = false 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, // 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; // stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count) // `presents` counts frames that reached glass (the display meter's sample count)
@@ -705,12 +689,9 @@ final class SessionModel: ObservableObject {
// captured before the 2026-07 floor policy); the appended trio carries the // captured before the 2026-07 floor policy); the appended trio carries the
// measured OS present floor and the floor-shaved values the HUD displays. // measured OS present floor and the floor-shaved values the HUD displays.
let line = String( let line = String(
// Swift Int is 64-bit %lld, NOT %d (which is a 32-bit C int); macOS 26's format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
// strict String(format:) validator rejects the %d/Int mismatch and drops + "decode_p50=%.1f display_p50=%.1f lost=%d "
// the whole line (a cascade error that also mis-blames the float args). + "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
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",
frames, frames,
displayWindow?.count ?? 0, displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1, self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -721,8 +702,7 @@ final class SessionModel: ObservableObject {
lost, lost,
self.osFloorValid ? self.osFloorP50Ms : -1, self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1, self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1, self.endToEndValid ? self.endToEndAdjP50Ms : -1)
self.clientQueueValid ? self.clientQueueP50Ms : -1)
statsLog.info("\(line, privacy: .public)") statsLog.info("\(line, privacy: .public)")
} }
} }
@@ -118,16 +118,6 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary) .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 { } else if model.hostNetworkValid {
// Stage-1 fallback presenter: the layer decodes + presents internally with no // Stage-1 fallback presenter: the layer decodes + presents internally with no
@@ -43,9 +43,6 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.presentPriority) private var presentPriority = @AppStorage(DefaultsKey.presentPriority) private var presentPriority =
SettingsOptions.presentPriorityDefault SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0 @AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
#if os(macOS)
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
#endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false @AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif #endif
@@ -348,22 +345,6 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.", detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled), 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) #if os(iOS)
// The device-rumble mirror slots in after "Controller type" (staying inside the // The device-rumble mirror slots in after "Controller type" (staying inside the
// Controller group the next row carries the "Interface" header). iPhone only in // 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.") { + "of added latency. Off shows frames as soon as they're ready.") {
Toggle("V-Sync", isOn: $vsync) 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 #endif
} }
} }
@@ -447,14 +435,6 @@ extension SettingsView {
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path). /// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View { @ViewBuilder var inputSection: some View {
Section("Keyboard & mouse") { 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) { described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
Picker("Modifier keys", selection: $modifierLayout) { Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in 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 #endif
// MARK: - Audio // MARK: - Audio
@@ -40,7 +40,6 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0 @AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false @AppStorage(DefaultsKey.vsync) var vsync = false
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
#endif #endif
#if !os(tvOS) #if !os(tvOS)
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true @AppStorage(DefaultsKey.allowVRR) var allowVRR = true
@@ -89,7 +88,6 @@ struct SettingsView: View {
@State var customMode = false @State var customMode = false
#endif #endif
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
@AppStorage(DefaultsKey.speakerUID) var speakerUID = "" @AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
@AppStorage(DefaultsKey.micUID) var micUID = "" @AppStorage(DefaultsKey.micUID) var micUID = ""
@AppStorage(DefaultsKey.micChannel) var micChannel = 0 @AppStorage(DefaultsKey.micChannel) var micChannel = 0
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
public let ptsNs: UInt64 public let ptsNs: UInt64
public let frameIndex: UInt32 public let frameIndex: UInt32
public let flags: UInt32 public let flags: UInt32
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC, /// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
/// decrypted `PunktfunkFrame.received_ns`, ABI v9) the **received** measurement point of /// the **received** measurement point of design/stats-unification.md. The decode stage is
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the /// `decodedNs - receivedNs`, both client-local (no skew offset applies).
/// 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).
public let receivedNs: Int64 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 /// One Opus audio packet (48 kHz stereo, 5 ms frames) decode with AVAudioConverter
@@ -683,16 +662,11 @@ public final class PunktfunkConnection {
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
var ts = timespec() var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts) clock_gettime(CLOCK_REALTIME, &ts)
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec) let receivedNs = 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
return AccessUnit( return AccessUnit(
data: data, ptsNs: frame.pts_ns, data: data, ptsNs: frame.pts_ns,
frameIndex: frame.frame_index, flags: frame.flags, frameIndex: frame.frame_index, flags: frame.flags,
receivedNs: receivedNs, pulledNs: pulledNs) receivedNs: receivedNs)
case statusNoFrame: case statusNoFrame:
return nil return nil
case statusClosed: case statusClosed:
@@ -110,11 +110,11 @@ public final class InputCapture {
/// event itself is swallowed). Main queue. /// event itself is swallowed). Main queue.
public var onToggleCapture: (() -> Void)? public var onToggleCapture: (() -> Void)?
/// Fired on M (the mouse-model flip, capture desktop cross-client parity with the /// Fired on C (the client-side-cursor toggle flips between the captured/disassociated
/// SDL clients' Ctrl+Alt+Shift+M; detected here, like , so it works regardless of the /// relative path and the visible-cursor absolute path; detected here, like , so it works
/// current capture state and the event itself is swallowed). macOS only; the /// regardless of the current capture state and the event itself is swallowed). macOS only;
/// absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue. /// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
public var onToggleMouseMode: (() -> Void)? public var onToggleCursor: (() -> Void)?
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS /// 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 /// 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?() self.onToggleCapture?()
return nil return nil
} }
// M flips the mouse model (capture desktop the SDL clients' identical // C toggles the client-side cursor (visible-cursor absolute path vs the
// chord). Detected in both capture states, like , so the model can be set // captured relative path). keyCode 8 = kVK_ANSI_C; layout-independent so it
// before engaging. keyCode 46 = kVK_ANSI_M; layout-independent. Suppress the M // fires the same on any keyboard. Suppress the C (latched like 's Esc) so it
// (latched like 's Esc) so it doesn't type into the host, and swallow the // doesn't type into the host, and swallow the event so it doesn't beep.
// event so it doesn't beep. if event.keyCode == 8 /* C */, flags == [.command, .shift] {
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] { self.suppressedVK = 0x43 // VK_C the same physical C is en route via GC
self.suppressedVK = 0x4D // VK_M the same physical M is en route via GC self.onToggleCursor?()
self.onToggleMouseMode?()
return nil return nil
} }
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S the same set every other // 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") 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 /// 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 /// 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 /// 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.) // in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
// The shared PQdisplay-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084 // The shared PQdisplay-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
// EOTF 203-nit-anchored scene light BT.2020709 primaries extended-Reinhard rolloff // EOTF 203-nit-anchored scene light BT.2020709 primaries extended-Reinhard rolloff
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the // BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map the
// no-HDR-headroom fallback). macOS keeps real HDR windowed now see `WindowedPresentMode`. // latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
static inline float3 pqToSdr(float3 pq) { static inline float3 pqToSdr(float3 pq) {
const float m1 = 2610.0/16384.0; const float m1 = 2610.0/16384.0;
const float m2 = 78.84375; 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 // PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
// fold in depth-10 MSB packing) PQ RGB the shared SDR tail. Used when a PQ pyrowave // fold in depth-10 MSB packing) PQ RGB 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 // 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). // layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]], fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
@@ -286,51 +259,21 @@ public final class MetalVideoPresenter {
public let layer: CAMetalLayer public let layer: CAMetalLayer
#if os(macOS) #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): /// Why this exists the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
/// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` an /// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
/// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's /// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
/// compositor on a high-refresh COMPOSITED (windowed) session the compositor's notion of the /// displays, and the race survives glass pacing a fully serialized one-in-flight present
/// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass /// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
/// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio /// using the image queue entirely and present the way video players do: render the planar CSC
/// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21 /// into an IOSurface pool and swap `contents` on main WindowServer treats it as ordinary
/// so it is the async image queue itself, at any pacing or codec, not a present rate. /// 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
/// The fix keeps the full render path (rgba16Float / PQ / EDR real HDR is preserved) and /// promotion, no compositing, no panic reports). Contents updates are transparent to the
/// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it /// layer below when nil, so flipping modes just covers/uncovers the metal layer.
/// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then public let surfaceLayer: CALayer = {
/// 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 = {
let l = CALayer() let l = CALayer()
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
l.isOpaque = true l.isOpaque = true
@@ -338,8 +281,8 @@ public final class MetalVideoPresenter {
return l return l
}() }()
/// One IOSurface-backed render target of the windowed surface-present pool. All pool state /// One IOSurface-backed render target of the windowed present pool. All pool state is
/// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap). /// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
private struct SurfaceSlot { private struct SurfaceSlot {
let surface: IOSurfaceRef let surface: IOSurfaceRef
let texture: MTLTexture let texture: MTLTexture
@@ -349,52 +292,15 @@ public final class MetalVideoPresenter {
private var surfacePool: [SurfaceSlot] = [] private var surfacePool: [SurfaceSlot] = []
private var surfacePoolSize: CGSize = .zero private var surfacePoolSize: CGSize = .zero
private var surfacePoolHDR = false
private var surfaceSeq: UInt64 = 0 private var surfaceSeq: UInt64 = 0
/// Index of the slot most recently handed to the layer never rewritten next, even if its /// 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). /// use count already dropped (the compositor may still be scanning out the previous frame).
private var lastHandedOff: Int? private var lastHandedOff: Int?
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
/// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis /// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
/// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present, private var surfacePresentsStaged = false
/// and how many presents/swaps were issued. The pf-present line shows the GLASS side /// Render-thread copy, so pool teardown happens exactly once on a mode flip.
/// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a private var surfacePresentsActive = false
/// 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()
#endif #endif
private let device: MTLDevice private let device: MTLDevice
@@ -410,7 +316,7 @@ public final class MetalVideoPresenter {
private let pipelinePlanar: MTLRenderPipelineState private let pipelinePlanar: MTLRenderPipelineState
/// PyroWave planar HDR passthrough (pf_frag_planar rgba16Float; the layer's PQ colour /// PyroWave planar HDR passthrough (pf_frag_planar rgba16Float; the layer's PQ colour
/// space + EDR interpret the samples) and the planar PQSDR tone-map (pf_frag_planar_tm /// space + EDR interpret the samples) and the planar PQSDR 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 pipelinePlanarHDR: MTLRenderPipelineState
private let pipelinePlanarToneMap: MTLRenderPipelineState private let pipelinePlanarToneMap: MTLRenderPipelineState
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
@@ -685,14 +591,13 @@ public final class MetalVideoPresenter {
} }
#if os(macOS) #if os(macOS)
/// Park the windowed present mechanism (MAIN thread the hosting view pushes its window /// Park the windowed-vs-fullscreen present routing (MAIN thread the hosting view pushes its
/// state on every layout; SessionPresenter resolves the mechanism per session). `.async` = /// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
/// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` / /// (the DCP swapID-panic mitigation see `surfaceLayer`); false = the CAMetalLayer path.
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms see `WindowedPresentMode`.
/// Applied by the render thread on the next frame, like every other staged value here. /// 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() stagingLock.lock()
windowedPresentStaged = mode surfacePresentsStaged = on
stagingLock.unlock() stagingLock.unlock()
} }
#endif #endif
@@ -829,12 +734,36 @@ public final class MetalVideoPresenter {
) -> Bool { ) -> Bool {
stagingLock.lock() stagingLock.lock()
let targetFromLayout = drawableTarget let targetFromLayout = drawableTarget
#if os(macOS)
let surfaceMode = surfacePresentsStaged
#endif
stagingLock.unlock() stagingLock.unlock()
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path // 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 // macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
// transactional present in `encodePresent`, not a colour downgrade). #if os(macOS)
configure(hdr: planes.pq && !surfaceMode)
#else
configure(hdr: planes.pq) configure(hdr: planes.pq)
#endif
var csc = planes.csc 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) // 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 // 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). // 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 /// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule /// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
/// the present and the on-glass callback. /// the present and the on-glass callback.
@@ -895,36 +936,6 @@ public final class MetalVideoPresenter {
#if DEBUG #if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize) logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
#endif #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, if let providedDrawable,
providedDrawable.texture.pixelFormat != layer.pixelFormat { providedDrawable.texture.pixelFormat != layer.pixelFormat {
return false // config outran the vend (HDR flip) next vend has the new format return false // config outran the vend (HDR flip) next vend has the new format
@@ -963,61 +974,6 @@ public final class MetalVideoPresenter {
} }
#endif #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 fullscreenwindowed 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); // 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. // immediate otherwise. A target already in the past presents immediately same thing.
if let presentAtMediaTime { if let presentAtMediaTime {
@@ -1025,146 +981,12 @@ public final class MetalVideoPresenter {
} else { } else {
commandBuffer.present(drawable) commandBuffer.present(drawable)
} }
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
commandBuffer.commit() commandBuffer.commit()
return true 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 /// 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. /// draw the MTLTexture is only valid while its CVMetalTexture is retained.
private func makeTexture( private func makeTexture(
@@ -142,17 +142,20 @@ enum PresentPriority: Equatable {
final class SessionPresenter { final class SessionPresenter {
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2 /// 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 /// default, macOS PyroWave sessions ALSO get glass gating a kernel-panic mitigation, not a
/// fix (that is the windowed transactional present see `setComposited`). PyroWave's wavelet /// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
/// decode is near-instant Metal compute, so a network clump presents within the same /// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
/// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the /// out-of-band CAMetalLayer presents (displaySyncEnabled=false mandatory for us, see
/// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass /// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
/// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead /// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
/// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt /// decode is near-instant Metal compute, so a network clump of frames presents within the
/// disproven: a fully serialized stream still panicked, which is why the real fix moved to the /// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
/// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing /// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
/// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency /// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
/// spaces their presents. /// 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( static func pacing(
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
) -> PresentPacing { ) -> 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 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. /// 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` (13) still overrides on iOS/tvOS so the standing-queue ladder /// `PUNKTFUNK_GATE_DEPTH` (13) 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 /// stays reproducible on-device; macOS is pinned to 1, env ignored glass pacing exists
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness /// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests. /// serialization is its point. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int { static func gateDepth(env: String?) -> Int {
#if os(macOS) #if os(macOS)
return 1 return 1
@@ -208,16 +196,10 @@ final class SessionPresenter {
private var stage2Link: CADisplayLink? private var stage2Link: CADisplayLink?
private var metalLayer: CAMetalLayer? private var metalLayer: CAMetalLayer?
#if os(macOS) #if os(macOS)
/// The windowed present MECHANISM this session runs while composited (resolved once per /// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
/// session in `start` the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT /// routing pushed to the pipeline see `setComposited`. Main-thread only, like all of this.
/// 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.
private var surfaceLayer: CALayer? private var surfaceLayer: CALayer?
private var surfacePresentsActive = false
#endif #endif
private var connection: PunktfunkConnection? private var connection: PunktfunkConnection?
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's /// 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) baseLayer.addSublayer(metal)
metalLayer = metal metalLayer = metal
#if os(macOS) #if os(macOS)
windowedPresentApplied = .async // The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
// Resolve THIS session's windowed mechanism once (setting + dev env lever) // contents) while the metal path presents, covering it while surface presents run.
// `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.
baseLayer.addSublayer(pipeline.surfaceLayer) baseLayer.addSublayer(pipeline.surfaceLayer)
surfaceLayer = pipeline.surfaceLayer surfaceLayer = pipeline.surfaceLayer
surfacePresentsActive = false
#endif #endif
stage2 = pipeline stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger // The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
@@ -456,23 +432,19 @@ final class SessionPresenter {
#if os(macOS) #if os(macOS)
/// Route presents for the window's composited state (MAIN thread the view pushes it on /// 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) /// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
/// session presents through this session's resolved mitigation mechanism (`windowedMode` /// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
/// transactional by default, see `windowedPresentMode`) instead of the async image queue /// CAMetalLayer image queue the DCP "mismatched swapID's" kernel-panic mitigation (see
/// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the /// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
/// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs: /// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
/// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 /// HDR/EDR presentation has no surface-contents equivalent wired.
/// 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.
func setComposited(_ composited: Bool) { func setComposited(_ composited: Bool) {
guard let stage2 else { return } guard let stage2, let connection else { return }
let mode: WindowedPresentMode = composited ? windowedMode : .async let wantsSurface = composited && connection.videoCodec == .pyrowave
guard mode != windowedPresentApplied else { return } guard wantsSurface != surfacePresentsActive else { return }
let wasSurface = windowedPresentApplied == .surface surfacePresentsActive = wantsSurface
windowedPresentApplied = mode stage2.setSurfacePresents(wantsSurface)
stage2.setWindowedPresent(mode) if !wantsSurface {
if wasSurface {
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen // 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). // entry shows the previous frame until the next present no black flash).
CATransaction.begin() CATransaction.begin()
@@ -499,7 +471,7 @@ final class SessionPresenter {
#if os(macOS) #if os(macOS)
surfaceLayer?.removeFromSuperlayer() surfaceLayer?.removeFromSuperlayer()
surfaceLayer = nil surfaceLayer = nil
windowedPresentApplied = .async surfacePresentsActive = false
#endif #endif
connection = nil connection = nil
} }
@@ -1114,15 +1114,15 @@ public final class Stage2Pipeline {
} }
#if os(macOS) #if os(macOS)
/// Forward the windowed present mechanism (MAIN thread see /// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` the
/// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation). /// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
func setWindowedPresent(_ mode: WindowedPresentMode) { public var surfaceLayer: CALayer { presenter.surfaceLayer }
presenter.setWindowedPresent(mode)
}
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling /// Forward the windowed-vs-fullscreen present routing (MAIN thread see
/// ABOVE `layer` (transparent while unused see `MetalVideoPresenter.surfaceLayer`). /// `MetalVideoPresenter.setSurfacePresents`).
var surfaceLayer: CALayer { presenter.surfaceLayer } public func setSurfacePresents(_ on: Bool) {
presenter.setSurfacePresents(on)
}
#endif #endif
/// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen` /// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen`
@@ -1213,9 +1213,7 @@ public final class Stage2Pipeline {
let chunkAligned = let chunkAligned =
au.flags & PunktfunkConnection.userFlagChunkAligned != 0 au.flags & PunktfunkConnection.userFlagChunkAligned != 0
let ptsNs = au.ptsNs let ptsNs = au.ptsNs
// Decode stage starts at the PULL (matching the VT path's FrameContext let receivedNs = au.receivedNs
// receiptpull is the HUD's separate client-queue term, ABI v9 split).
let receivedNs = au.pulledNs
let flags = au.flags let flags = au.flags
let submitted = decoder.decode( let submitted = decoder.decode(
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
public struct ReadyFrame: @unchecked Sendable { public struct ReadyFrame: @unchecked Sendable {
/// Host capture clock (the AU's pts), in nanoseconds. /// Host capture clock (the AU's pts), in nanoseconds.
public let ptsNs: UInt64 public let ptsNs: UInt64
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded /// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
/// through the decode via the frame refcon), in nanoseconds the decode stage's start /// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
/// point. (Named for its historical role; since the ABI v9 receipt split the true /// didn't stamp receipt) the decode-stage meter then drops the sample via its sanity guard.
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receiptpull 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.
public let receivedNs: Int64 public let receivedNs: Int64
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds. /// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
public let decodedNs: Int64 public let decodedNs: Int64
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
var infoOut = VTDecodeInfoFlags() var infoOut = VTDecodeInfoFlags()
// The AU's receipt instant + wire flags ride through as a retained context; the output // 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. // 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 let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
// receipt: both consumers the decode-stage meter and the ABR decode signal are
// specified from the pull, and the receiptpull wait is the HUD's separate client-queue
// term (see AccessUnit.pulledNs).
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
let refcon = Unmanaged.passRetained(ctx).toOpaque() let refcon = Unmanaged.passRetained(ctx).toOpaque()
let status = VTDecompressionSessionDecodeFrame( let status = VTDecompressionSessionDecodeFrame(
session, session,
@@ -38,11 +38,10 @@ private let streamInputDebug =
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it. /// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
/// hide/unhide and associate are balanced via `captured`. /// hide/unhide and associate are balanced via `captured`.
/// ///
/// In the DESKTOP mouse model (absolute pointer, remote-desktop-sweep M1) this is a no-op: /// In CLIENT-SIDE-CURSOR mode (gamescope, whose capture carries no host cursor) this is a
/// the pointer stays free (entering and leaving the stream at will) and StreamLayerView /// no-op: the local cursor stays visible and free, and StreamLayerView forwards ABSOLUTE
/// forwards ABSOLUTE positions instead; the local cursor is hidden only while over the view /// positions instead the visible system cursor IS the on-screen cursor. `disassociate`
/// (cursor rects). `disassociate` selects between the two; `release()` only undoes what /// selects between the two; `release()` only undoes what `capture` actually did.
/// `capture` actually did.
private final class CursorCapture { private final class CursorCapture {
private var captured = false private var captured = false
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode), /// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
@@ -208,17 +207,14 @@ public final class StreamLayerView: NSView {
/// forwarded). Main-thread only. /// forwarded). Main-thread only.
public private(set) var captured = false public private(set) var captured = false
/// Desktop (absolute) mouse model remote-desktop-sweep M1: when true the pointer is /// Client-side-cursor mode: when true the local system cursor stays VISIBLE over the
/// never disassociated (it enters and leaves the stream freely) and the mouse monitor /// stream and the mouse monitor forwards ABSOLUTE positions (the visible cursor is the
/// forwards ABSOLUTE positions through the letterbox; the local cursor is hidden only /// on-screen cursor gamescope draws none, so no double cursor); when false the existing
/// while over this view (cursor rects the host's composited cursor, tracking our /// captured/disassociated relative path runs unchanged. Initialized at session start from
/// sends, is the one you see) and reappears the moment it leaves. When false the /// the `cursorMode` setting + the host's resolved compositor, toggled live by C. A live
/// captured/disassociated relative path runs unchanged. Initialized at session start /// flip re-engages capture in the new mode so disassociation + the abs/rel choice swap
/// from the `mouseMode` setting gated by the host's resolved compositor (gamescope's /// atomically. Main-thread only.
/// EIS is relative-only absolute sends would be dropped, so it pins to capture); private var cursorVisible = false
/// 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
/// One-shot auto-engage request (stream start, trust confirmed) attempted as soon /// 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 /// as the view is in a window with real bounds, then dropped, so it can never fire
/// surprisingly later (e.g. on a resize). /// surprisingly later (e.g. on a resize).
@@ -444,9 +440,9 @@ public final class StreamLayerView: NSView {
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is // 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 // 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. // 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. // 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) inputCapture?.setForwarding(true, suppressClick: fromClick)
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded // 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 // delta (the monitor isn't up yet), and the engage click's suppression latch is
@@ -454,7 +450,6 @@ public final class StreamLayerView: NSView {
installMouseMonitor() installMouseMonitor()
captured = true captured = true
window?.makeFirstResponder(self) window?.makeFirstResponder(self)
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
notifyCaptureChange(true) notifyCaptureChange(true)
} }
@@ -464,28 +459,9 @@ public final class StreamLayerView: NSView {
cursorCapture.release() cursorCapture.release()
inputCapture?.setForwarding(false) inputCapture?.setForwarding(false)
captured = false captured = false
window?.invalidateCursorRects(for: self)
notifyCaptureChange(false) notifyCaptureChange(false)
} }
/// 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 {
addCursorRect(bounds, cursor: Self.invisibleCursor)
} else {
super.resetCursorRects()
}
}
/// A single local monitor for motion + buttons, installed only while captured. A local /// A single local monitor for motion + buttons, installed only while captured. A local
/// monitor is more robust than view overrides for relative motion: it sidesteps the /// monitor is more robust than view overrides for relative motion: it sidesteps the
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and /// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
@@ -497,12 +473,12 @@ public final class StreamLayerView: NSView {
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're /// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
/// inert locally. /// 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 /// 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. /// restore it on removal so absolute hover-motion keeps flowing without a click held.
private func installMouseMonitor() { private func installMouseMonitor() {
guard mouseEventMonitor == nil else { return } guard mouseEventMonitor == nil else { return }
if desktopMouse { if cursorVisible {
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
window?.acceptsMouseMovedEvents = true window?.acceptsMouseMovedEvents = true
} }
@@ -514,8 +490,8 @@ public final class StreamLayerView: NSView {
guard let self, self.captured, let ic = self.inputCapture else { return event } guard let self, self.captured, let ic = self.inputCapture else { return event }
switch event.type { switch event.type {
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
if self.desktopMouse { if self.cursorVisible {
// Desktop mouse model: forward the ABSOLUTE position (mapped through the // Client-side cursor: forward the ABSOLUTE position (mapped through the
// aspect-fit letterbox into host pixels), the same path the iPad pointer // aspect-fit letterbox into host pixels), the same path the iPad pointer
// fallback uses. Events in the letterbox bars are dropped (nil host point). // fallback uses. Events in the letterbox bars are dropped (nil host point).
if let p = self.hostPoint(from: event) { if let p = self.hostPoint(from: event) {
@@ -633,27 +609,14 @@ public final class StreamLayerView: NSView {
// be a cursor trap with dead input. // be a cursor trap with dead input.
self?.releaseCapture() self?.releaseCapture()
} }
// M flips the mouse model (capture desktop) live the SDL clients' identical // C flips the client-side cursor live. Only the key window's stream owns it (same
// chord. Only the key window's stream owns it (same guard as the capture toggle). // guard as the capture toggle). Re-engage capture in the new mode so disassociation
// Re-engage capture in the new model so disassociation and the absolute/relative // and the absolute/relative forwarding choice swap atomically releaseCapture restores
// forwarding choice swap atomically releaseCapture restores the old model's grab // the old mode's grab (if any), engageCapture installs the new one.
// (if any), engageCapture installs the new one. On a gamescope host the chord is a // C would flip the client-side cursor live NEUTERED while the feature is disabled
// no-op: its EIS grants only a relative pointer, so the desktop model's absolute // (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
// sends would be silently dropped (pointer stuck = "all input dead"). // input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
capture.onToggleMouseMode = { [weak self] in capture.onToggleCursor = {}
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
}
let wasCaptured = self.captured
if wasCaptured { self.releaseCapture() }
self.desktopMouse.toggle()
if wasCaptured { self.engageCapture(fromClick: false) }
self.window?.invalidateCursorRects(for: self)
streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
}
// The cross-client combos (Q/D/S Ctrl+Alt+Shift on the other clients), delivered by // The 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. // the monitor only while captured; the same key-window ownership rule as throughout.
capture.onReleaseCapture = { [weak self] in capture.onReleaseCapture = { [weak self] in
@@ -680,18 +643,15 @@ public final class StreamLayerView: NSView {
capture.start() capture.start()
inputCapture = capture inputCapture = capture
// Desktop (absolute) mouse model resolved at session start from the mouseMode // Client-side cursor is TEMPORARILY DISABLED. It positions the host cursor with ABSOLUTE
// setting, gated by the host's compositor: gamescope's input socket (EIS) grants // events, but gamescope's input socket (EIS) grants only a relative pointer, so those are
// only a relative pointer, so absolute sends would be silently dropped there // silently dropped the pointer never moves and clicks/scroll land on the stuck position
// (pointer stuck = "all input dead") pinned to capture. M flips it live. // (looks like "all input dead"). gamescope is exactly the compositor Auto enabled it for.
let mode = MouseInputMode( // Forced off until per-compositor gating (KWin/GNOME/Sway have absolute) or a synthetic-
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "" // cursor-over-relative path lands; the resolution logic below is kept for that. See the
) ?? .capture // C handler (also neutered) and the cursorMode setting (hidden).
let absOK = connection.resolvedCompositor != .gamescope cursorVisible = false
desktopMouse = mode == .desktop && absOK _ = connection.resolvedCompositor // (was: Auto gamescope; kept to document intent)
if mode == .desktop && !absOK {
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
}
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2 // Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
@@ -740,9 +700,9 @@ public final class StreamLayerView: NSView {
private func layoutPresenter() { private func layoutPresenter() {
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
// Present routing tracks the window's composited state (fullscreen transitions always // 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 // re-layout, so this stays current): windowed PyroWave presents via surface contents
// transaction the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). // the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
// A view not yet in a window counts as composited (the safe default). // not yet in a window counts as composited (the safe default).
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false)) presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
// Feed the follower only once in a window (backing scale is real then) and with real // 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. // bounds a pre-window layout would report point-sized dimensions.
@@ -70,16 +70,6 @@ public enum DefaultsKey {
/// (lowest latency the default, OFF). Resolved once per session; /// (lowest latency the default, OFF). Resolved once per session;
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header. /// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
public static let vsync = "punktfunk.vsync" 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, /// 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 /// 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, /// 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. /// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
public static let enable444 = "punktfunk.enable444" public static let enable444 = "punktfunk.enable444"
public static let hosts = "punktfunk.hosts" public static let hosts = "punktfunk.hosts"
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or /// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
/// "desktop" (uncaptured absolute pointer) the cross-client `mouse_mode`. Replaces the public static let cursorMode = "punktfunk.cursorMode"
/// 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"
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by /// 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 /// 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 /// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
@@ -316,41 +316,6 @@ final class PresentPacingTests: XCTestCase {
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline) 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 // MARK: - Glass-gate depth
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue /// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue
+2 -11
View File
@@ -16,8 +16,6 @@
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) # 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_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_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_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # 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 echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
exit 2 exit 2
fi 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 if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title. # 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 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 fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 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
+7 -32
View File
@@ -70,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). // 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 const ART_VERSION = 2;
// on those installs — the bump makes them re-apply once on the first build that has the files.
const ART_VERSION = 3;
function artKey(appId: number): string { function artKey(appId: number): string {
return `punktfunk:shortcutArt:${appId}`; return `punktfunk:shortcutArt:${appId}`;
} }
@@ -81,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 * 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. * 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 { try {
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) { if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
return; return;
@@ -93,29 +91,16 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
[art.logo, 2], [art.logo, 2],
[art.gridwide, 3], [art.gridwide, 3],
]; ];
let applied = false;
for (const [data, assetType] of assets) { for (const [data, assetType] of assets) {
if (data) { if (data) {
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType); await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
applied = true;
} }
} }
if (art.icon_path) { if (art.icon_path) {
SteamClient.Apps.SetShortcutIcon(appId, 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) { } 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); console.warn("punktfunk: shortcut artwork not applied", e);
} }
} }
@@ -172,9 +157,7 @@ async function ensureControllerConfig(): Promise<void> {
return; return;
} }
const r = await applyControllerConfig(SHORTCUT_NAME); const r = await applyControllerConfig(SHORTCUT_NAME);
// `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend if (r?.ok) {
// succeeds without pointing any account at the template — keep retrying until one lands.
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`); localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
} else { } else {
console.warn("punktfunk: controller config not fully applied", r); console.warn("punktfunk: controller config not fully applied", r);
@@ -300,21 +283,13 @@ export async function launchStream(
opts: LaunchOpts = {}, opts: LaunchOpts = {},
): Promise<void> { ): Promise<void> {
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now // 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 // so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is // 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. // 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 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 target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`]; 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) { if (opts.browse) {
env.push("PF_BROWSE=1"); env.push("PF_BROWSE=1");
if (opts.mgmt) { if (opts.mgmt) {
@@ -328,9 +303,9 @@ export async function launchStream(
env.push(`PF_LAUNCH=${opts.launchId}`); env.push(`PF_LAUNCH=${opts.launchId}`);
} }
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper // 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 // script rides behind it as an argument and reads PF_* from the environment.
// awaited above, so the magic packet is out before the connect attempt.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); 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); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
+1 -40
View File
@@ -69,14 +69,6 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
"The cursor jumps to your finger — a tap clicks there", "The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps", "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. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
@@ -550,20 +542,6 @@ pub fn show(
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]); 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() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .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); touch_row.set_selected(touch_i as u32);
// set_selected never fires the changed hook, so seed the dynamic caption directly. // set_selected never fires the changed hook, so seed the dynamic caption directly.
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]); 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 let comp_i = COMPOSITORS
.iter() .iter()
.position(|&c| c == s.compositor) .position(|&c| c == s.compositor)
@@ -816,7 +788,6 @@ pub fn show(
touch_group.add(touch_row.widget()); touch_group.add(touch_row.widget());
// Group titles are Pango markup — the ampersand must be an entity. // Group titles are Pango markup — the ampersand must be an entity.
let kbm_group = group("Keyboard &amp; mouse", ""); let kbm_group = group("Keyboard &amp; mouse", "");
kbm_group.add(mouse_row.widget());
kbm_group.add(&inhibit_row); kbm_group.add(&inhibit_row);
kbm_group.add(&invert_row); kbm_group.add(&invert_row);
input.add(&touch_group); input.add(&touch_group);
@@ -885,19 +856,9 @@ pub fn show(
s.render_scale = s.render_scale =
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)]; RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; 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 s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
// 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.touch_mode = s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); 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.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .to_string();
+13 -26
View File
@@ -458,11 +458,7 @@ async fn session(args: Args) -> Result<()> {
), ),
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"), (None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
} }
let (mut send, recv) = conn.open_bi().await.context("open control stream")?; let (mut send, mut 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);
io::write_msg( io::write_msg(
&mut send, &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. // 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 // 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. // 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 let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ | punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() { if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT; caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
} }
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() { if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_444; 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 caps
}, },
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the // `--audio-channels` (default stereo); the probe multistream-decodes + validates the
@@ -527,8 +513,8 @@ async fn session(args: Args) -> Result<()> {
.encode(), .encode(),
) )
.await?; .await?;
let welcome = let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?; .map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
tracing::info!( tracing::info!(
mode = ?welcome.mode, mode = ?welcome.mode,
fec = ?welcome.fec, fec = ?welcome.fec,
@@ -542,11 +528,6 @@ async fn session(args: Args) -> Result<()> {
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444, chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
chroma_format_idc = welcome.chroma_format, chroma_format_idc = welcome.chroma_format,
codec = codec_ext(welcome.codec), codec = codec_ext(welcome.codec),
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
"chacha20-poly1305"
} else {
"aes-128-gcm"
},
"session offer" "session offer"
); );
@@ -648,7 +629,10 @@ async fn session(args: Args) -> Result<()> {
tracing::error!("Reconfigure write failed"); tracing::error!("Reconfigure write failed");
return; 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 => { Ok(Ok(ack)) if ack.accepted => {
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED") tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
} }
@@ -701,7 +685,10 @@ async fn session(args: Args) -> Result<()> {
tracing::error!("SetBitrate write failed"); tracing::error!("SetBitrate write failed");
return; 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!( Ok(Ok(ack)) => tracing::info!(
applied_kbps = ack.bitrate_kbps, applied_kbps = ack.bitrate_kbps,
"BITRATE CHANGE acked by host" "BITRATE CHANGE acked by host"
@@ -763,7 +750,7 @@ async fn session(args: Args) -> Result<()> {
tracing::error!("ProbeRequest write failed"); tracing::error!("ProbeRequest write failed");
return; 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, Ok(Ok(r)) => r,
other => { other => {
tracing::error!(?other, "bad ProbeResult"); tracing::error!(?other, "bad ProbeResult");
+2 -6
View File
@@ -27,13 +27,9 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub # Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
# binary. # binary.
[target.'cfg(any(target_os = "linux", windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single pf-presenter = { path = "../../crates/pf-presenter" }
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true } pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
pf-client-core = { path = "../../crates/pf-client-core", default-features = false } pf-client-core = { path = "../../crates/pf-client-core" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON. # The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
-1
View File
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
v => v, v => v,
}, },
touch_mode: settings_at_start.touch_mode(), touch_mode: settings_at_start.touch_mode(),
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll, invert_scroll: settings_at_start.invert_scroll,
json_status, json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
-1
View File
@@ -429,7 +429,6 @@ mod session_main {
v => v, v => v,
}, },
touch_mode: settings.touch_mode(), touch_mode: settings.touch_mode(),
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll, invert_scroll: settings.invert_scroll,
json_status: true, json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
+1 -11
View File
@@ -29,17 +29,7 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the # The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library # shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
# data model (fetch + art pipeline) behind the library page. # data model (fetch + art pipeline) behind the library page.
# pf-client-core = { path = "../../crates/pf-client-core" }
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
# nothing enables it and the C++ is never compiled.
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its # WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri # `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
+3 -8
View File
@@ -59,14 +59,9 @@
</Application> </Application>
<!-- <!--
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe) desktop shell is the wrong first screen. Same full-trust executable, launched with
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands `--console`, which hands straight off to the session binary's controller-driven
straight off to the session binary's controller-driven browse mode (host list, browse mode (host list, pairing, settings, library) fullscreen.
pairing, settings, library) fullscreen.
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
--> -->
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe" <Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
EntryPoint="Windows.FullTrustApplication"> EntryPoint="Windows.FullTrustApplication">
-18
View File
@@ -90,13 +90,6 @@ const TOUCH_MODES: &[(&str, &str)] = &[
("pointer", "Direct pointer"), ("pointer", "Direct pointer"),
("touch", "Touch passthrough"), ("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 /// 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. /// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[ 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| { let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string(); 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 = let invert_scroll_toggle =
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| { setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
s.invert_scroll = on s.invert_scroll = on
@@ -553,13 +542,6 @@ pub(crate) fn settings_page(
out.extend(group( out.extend(group(
Some("Keyboard & mouse"), Some("Keyboard & mouse"),
vec![ 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( described(
shortcuts_toggle, shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \ "Alt+Tab, the Windows key and friends reach the host while the stream \
-16
View File
@@ -24,16 +24,6 @@ use pf_frame::DmabufFrame;
pub trait Capturer: Send { pub trait Capturer: Send {
fn next_frame(&mut self) -> Result<CapturedFrame>; 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 /// 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 /// 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 /// just produces a frame each call — fine for instant synthetic sources; the portal
@@ -259,12 +249,6 @@ pub struct ZeroCopyPolicy {
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only /// 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`. /// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
pub pyrowave_session: bool, 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, /// 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, /// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise. /// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
+97 -229
View File
@@ -299,11 +299,29 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer { impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> { fn next_frame(&mut self) -> Result<CapturedFrame> {
self.frame_within(Duration::from_secs(10)) // First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
// instead of sitting out the full first-frame budget.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly rebuilding capture",
self.node_id
));
}
if let Some(f) = self.pending.take() {
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
}
let slice = Duration::from_millis(500)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
match self.frames.recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
Err(e) => return self.next_frame_timed_out(e),
}
} }
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
self.frame_within(budget)
} }
fn supports_arrival_wait(&self) -> bool { fn supports_arrival_wait(&self) -> bool {
@@ -399,41 +417,9 @@ impl Capturer for PortalCapturer {
} }
impl PortalCapturer { impl PortalCapturer {
/// The blocking first-frame wait behind [`Capturer::next_frame`] / /// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later /// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
let deadline = std::time::Instant::now() + budget;
loop {
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly rebuilding capture",
self.node_id
));
}
if let Some(f) = self.pending.take() {
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
}
let slice = Duration::from_millis(500)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
match self.frames.recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
Err(e) => return self.next_frame_timed_out(e, budget),
}
}
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
&self,
err: RecvTimeoutError,
budget: Duration,
) -> Result<CapturedFrame> {
let within = budget.as_secs_f32();
match err { match err {
RecvTimeoutError::Timeout => { RecvTimeoutError::Timeout => {
// Split the two black-screen root causes apart so the operator gets a cause, not // Split the two black-screen root causes apart so the operator gets a cause, not
@@ -441,10 +427,9 @@ impl PortalCapturer {
// not (no acceptable format / node never emitted a param)? // not (no acceptable format / node never emitted a param)?
if self.negotiated.load(Ordering::Relaxed) { if self.negotiated.load(Ordering::Relaxed) {
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiated but no \ "no PipeWire frame within 10s (node {}): format negotiated but no buffers \
buffers arrived the compositor produced no frames (virtual output \ arrived the compositor produced no frames (virtual output idle/unmapped, \
idle/unmapped, capture never started, or a stream bound during a \ or capture never started)",
compositor (re)start that will never deliver a reconnect fixes that)",
self.node_id self.node_id
)) ))
} else if self.hdr_offer { } else if self.hdr_offer {
@@ -455,10 +440,10 @@ impl PortalCapturer {
// auto-reconnects) negotiates SDR instead of re-running this same timeout. // auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed(); super::note_hdr_capture_failed();
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \ "no PipeWire frame within 10s (node {}): the compositor never accepted \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \ the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored monitor in \
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \ HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
reconnect to stream SDR", to stream SDR",
self.node_id self.node_id
)) ))
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() { } else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
@@ -467,15 +452,14 @@ impl PortalCapturer {
// retries on the CPU offer instead of failing this same negotiation forever. // retries on the CPU offer instead of failing this same negotiation forever.
pf_zerocopy::note_vaapi_dmabuf_failed(); pf_zerocopy::note_vaapi_dmabuf_failed();
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \ "no PipeWire frame within 10s (node {}): the compositor never accepted \
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) downgrading this \ the LINEAR-dmabuf offer (VAAPI zero-copy) downgrading this host to the \
host to the CPU capture path; the pipeline rebuild will renegotiate \ CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
without dmabuf",
self.node_id self.node_id
)) ))
} else { } else {
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \ "no PipeWire frame within 10s (node {}): format negotiation never \
completed the compositor offered no format this consumer accepts \ completed the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param", (pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id self.node_id
@@ -840,7 +824,6 @@ mod pipewire {
VideoFormat::RGBA => PixelFormat::Rgba, VideoFormat::RGBA => PixelFormat::Rgba,
VideoFormat::RGB => PixelFormat::Rgb, VideoFormat::RGB => PixelFormat::Rgb,
VideoFormat::BGR => PixelFormat::Bgr, VideoFormat::BGR => PixelFormat::Bgr,
VideoFormat::NV12 => PixelFormat::Nv12,
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by // The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020). // the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10, VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
@@ -1006,10 +989,10 @@ mod pipewire {
.into_inner()) .into_inner())
} }
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path; /// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up). /// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which
/// we read back in `param_changed`.
fn build_dmabuf_format( fn build_dmabuf_format(
format: VideoFormat,
modifiers: &[u64], modifiers: &[u64],
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
@@ -1020,7 +1003,7 @@ mod pipewire {
pw::spa::param::ParamType::EnumFormat, pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video), pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw), pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format), pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx),
pw::spa::pod::property!( pw::spa::pod::property!(
FormatProperties::VideoSize, FormatProperties::VideoSize,
Choice, Choice,
@@ -1049,22 +1032,6 @@ mod pipewire {
pw::spa::utils::Fraction { num: 240, 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 { obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier, key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY, flags: pw::spa::pod::PropertyFlags::MANDATORY,
@@ -1343,25 +1310,21 @@ mod pipewire {
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position. /// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) { 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). // 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_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically // `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below // (or null), valid until requeue. The size argument matches the struct the result is cast to.
// are ALL producer-written, and without a bound against the actual region they drive let cur = unsafe {
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read spa::sys::spa_buffer_find_meta_data(
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot spa_buf,
// catch). Every offset below is validated against `region_size` with checked arithmetic, spa::sys::SPA_META_Cursor,
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr. std::mem::size_of::<spa::sys::spa_meta_cursor>(),
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) }; ) as *const spa::sys::spa_meta_cursor
if meta.is_null() { };
if cur.is_null() {
return; return;
} }
// SAFETY: `meta` is non-null and points into the held buffer's metadata array. // SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) }; // inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
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 { let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
( (
(*cur).id, (*cur).id,
@@ -1384,18 +1347,13 @@ mod pipewire {
// Position-only update — keep the cached bitmap. // Position-only update — keep the cached bitmap.
return; return;
} }
let bmp_off = bmp_off as usize; // SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it — // producer placed inside the same meta region it sized for this cursor (>= the size we
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata. // requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) { let bmp =
Some(end) if end <= region_size => {} unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
_ => return, // SAFETY: `bmp` is the in-bounds, aligned `spa_meta_bitmap` pointer computed just above; the
} // producer fully initialized this header, so reading its scalar fields is sound.
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
// so the header is fully in bounds; the producer places it aligned as before.
let bmp = unsafe { data.add(bmp_off) as *const spa::sys::spa_meta_bitmap };
// SAFETY: `bmp` is the in-bounds `spa_meta_bitmap` header validated just above; reading its
// scalar fields is sound.
let (vfmt, bw, bh, stride, pix_off) = unsafe { let (vfmt, bw, bh, stride, pix_off) = unsafe {
( (
(*bmp).format, (*bmp).format,
@@ -1411,27 +1369,10 @@ mod pipewire {
} }
let row = bw as usize * 4; let row = bw as usize * 4;
let stride = if stride < row { row } else { stride }; let stride = if stride < row { row } else { stride };
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it let span = stride * (bh as usize - 1) + row;
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and // SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before // producer-sized meta region. `span` is the exact extent the strided copy below reads.
// fabricating the slice — this is the check whose absence made the read go out of bounds. let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
let span = match stride
.checked_mul(bh as usize - 1)
.and_then(|v| v.checked_add(row))
{
Some(s) => s,
None => return,
};
match bmp_off
.checked_add(pix_off)
.and_then(|v| v.checked_add(span))
{
Some(end) if end <= region_size => {}
_ => return,
}
// SAFETY: `bmp_off + pix_off + span <= region_size` (checked directly above), so the slice
// is fully within the producer's meta region; `span` is exactly the strided loop's extent.
let src = unsafe { std::slice::from_raw_parts(data.add(bmp_off + pix_off), span) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4]; let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize { for y in 0..bh as usize {
for x in 0..bw as usize { for x in 0..bw as usize {
@@ -1637,8 +1578,8 @@ mod pipewire {
} }
} }
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can // VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports
// be consumed by the Vulkan Video encoder without another color conversion. // it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here.
if ud.vaapi_passthrough { if ud.vaapi_passthrough {
if let Some(fmt) = ud.format { if let Some(fmt) = ud.format {
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
@@ -1646,41 +1587,9 @@ mod pipewire {
let chunk = datas[0].chunk(); let chunk = datas[0].chunk();
let offset = chunk.offset(); let offset = chunk.offset();
let stride = chunk.stride().max(0) as u32; let stride = chunk.stride().max(0) as u32;
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
// may align the Y plane before UV). Pass it through instead of assuming
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
// by inode, not fd number; a genuinely two-BO frame cannot travel through
// the single-fd import — drop it with a diagnosis instead of streaming
// garbage chroma.
let plane1 =
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
// owned by the live PipeWire buffer for this callback, and `fstat`
// only writes the out-param structs, whose fields are read only after
// the `== 0` success checks.
let same_bo = unsafe {
let mut s0: libc::stat = std::mem::zeroed();
let mut s1: libc::stat = std::mem::zeroed();
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
};
if !same_bo {
warn_once(
"NV12 planes live in different buffer objects — frames \
dropped (single-fd import only)",
);
return;
}
let c1 = datas[1].chunk();
Some((c1.offset(), c1.stride().max(0) as u32))
} else {
None
};
// dup the fd so it survives the SPA buffer recycle — the encode thread // dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. Content stability across the brief import/encode window relies // imports it. (Content stability across the brief map+CSC window relies on
// on the compositor's buffer-pool depth, like any zero-copy capture. // the compositor's buffer-pool depth, like any zero-copy capture.)
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid // SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd, // for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1). // touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
@@ -1707,10 +1616,9 @@ mod pipewire {
modifier: ud.modifier, modifier: ud.modifier,
offset, offset,
stride, stride,
plane1,
}), }),
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope // Cursor-as-metadata: the encoder blends this into its owned VA
// embeds its pointer in the produced pixels, so native NV12 has none. // surface (raw dmabuf never touched).
cursor: ud.cursor.overlay(), cursor: ud.cursor.overlay(),
}); });
static ONCE: std::sync::atomic::AtomicBool = static ONCE: std::sync::atomic::AtomicBool =
@@ -1721,12 +1629,7 @@ mod pipewire {
h, h,
modifier = ud.modifier, modifier = ud.modifier,
fourcc = format_args!("{:#010x}", fourcc), fourcc = format_args!("{:#010x}", fourcc),
source = if fmt == PixelFormat::Nv12 { "zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
"producer-native NV12"
} else {
"packed RGB (encoder GPU CSC)"
},
"zero-copy: handing the raw DMA-BUF to the encoder"
); );
} }
return; return;
@@ -2086,28 +1989,6 @@ mod pipewire {
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw // PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
// dmabuf straight to the encoder. // dmabuf straight to the encoder.
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough; let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
// packed-RGB fallback pod.
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
&& policy.native_nv12_session
&& backend_is_vaapi
&& vaapi_passthrough
&& !policy.pyrowave_session
&& !want_444
&& !want_hdr;
if prefer_native_nv12 {
tracing::info!(
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
);
}
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR // Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via // (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only: // CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
@@ -2147,9 +2028,7 @@ mod pipewire {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() { } else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
tracing::info!( tracing::info!(
native_nv12_preferred = prefer_native_nv12, "zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
when enabled, packed RGB fallback)"
); );
} else if want_dmabuf && !vaapi_passthrough { } else if want_dmabuf && !vaapi_passthrough {
tracing::info!( tracing::info!(
@@ -2285,37 +2164,36 @@ mod pipewire {
} }
}) })
.process(|stream, ud| { .process(|stream, ud| {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its // PipeWire dispatches this from a C trampoline with no catch_unwind; a
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue // panic crossing that FFI boundary would abort the whole host. Contain it.
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is // Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was // recycles its pool; an older queued buffer carries a STALE frame. Drain all
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded // queued buffers, requeue the older ones, keep only the newest.
// `newest` forever, permanently shrinking the stream's fixed pool until capture wedged. // SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the // the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null // a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
// (null-checked), single-threaded so no concurrent access. // null-checked before any use. The loop is single-threaded, so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() }; let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() { if newest.is_null() {
return; return;
} }
let mut drained = 1u32; let mut drained = 1u32;
loop { loop {
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null. // SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
let next = unsafe { stream.dequeue_raw_buffer() }; let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() { if next.is_null() {
break; break;
} }
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately // SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
// overwrite it, so the requeued pointer is never touched again. // stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
// touched again (no use-after-requeue). Loop thread, single-threaded.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
newest = next; newest = next;
drained += 1; drained += 1;
} }
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust
// code here that can panic — and requeue `newest` unconditionally after it.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued); // SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field // `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing. // load through a valid pointer — no mutation or aliasing.
@@ -2394,18 +2272,19 @@ mod pipewire {
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)" "capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
); );
} }
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below. // SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
// skip path); hand it back to the stream exactly once and return without touching it
// again. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) };
return; return;
} }
consume_frame(ud, spa_buf); consume_frame(ud, spa_buf);
})); // SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip, // from `newest`), so requeuing the owned `newest` exactly once here is sound — no
// or a caught panic in the closure above. This single requeue is what keeps the fixed // use-after-requeue. Loop thread inside `.process`.
// buffer pool from draining.
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
unsafe { stream.queue_raw_buffer(newest) }; unsafe { stream.queue_raw_buffer(newest) };
}));
if outcome.is_err() { if outcome.is_err() {
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad // In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to // format) would fire this every frame, so power-of-two throttle it — enough to
@@ -2489,18 +2368,7 @@ mod pipewire {
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?, build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
] ]
} else if want_dmabuf { } else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 }); vec![build_dmabuf_format(&modifiers, preferred)?]
if prefer_native_nv12 {
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
}
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
pods
} else { } else {
vec![serialize_pod(obj)?] vec![serialize_pod(obj)?]
}; };
+12 -209
View File
@@ -308,9 +308,8 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV /// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This /// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`] /// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime /// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall
/// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the /// back to the existing R10 path.
/// original design referenced was never kept.)
pub(crate) struct HdrP010Converter { pub(crate) struct HdrP010Converter {
vs: ID3D11VertexShader, vs: ID3D11VertexShader,
ps_y: ID3D11PixelShader, ps_y: ID3D11PixelShader,
@@ -738,157 +737,14 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL. /// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn hdr_p010_selftest() -> Result<()> { pub fn hdr_p010_selftest() -> Result<()> {
hdr_p010_selftest_at(64, 64, None) use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
} use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor // 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform →
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on // exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus
/// top of the 64×64 default because the field sessions run at capture resolutions whose height // a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar const W: u32 = 64;
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default const H: u32 = 64;
/// adapter is not the one the session encodes on.
/// 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)?;
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
Ok((device, p010))
}
}
#[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; const BLK: u32 = 16;
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values. // (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] = [ let named: [(&str, f32, f32, f32); 8] = [
@@ -941,36 +797,12 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are // `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below. // proven individually at the `read_u16` closure below.
unsafe { unsafe {
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else // Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
// 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 device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None; let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice( D3D11CreateDevice(
adapter.as_ref(), None::<&IDXGIAdapter>,
if adapter.is_some() { D3D_DRIVER_TYPE_HARDWARE,
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
},
HMODULE::default(), HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]), Some(&[D3D_FEATURE_LEVEL_11_0]),
@@ -982,22 +814,6 @@ pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?; .context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
let device = device.context("null device")?; let device = device.context("null device")?;
let context = context.context("null context")?; 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. // Source FP16 texture (initialized) + SRV.
let src_desc = D3D11_TEXTURE2D_DESC { let src_desc = D3D11_TEXTURE2D_DESC {
@@ -1359,16 +1175,3 @@ impl VideoConverter {
blt.context("VideoProcessorBlt") blt.context("VideoProcessorBlt")
} }
} }
#[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");
}
}
@@ -1341,12 +1341,6 @@ impl IddPushCapturer {
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None; self.hdr_p010_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
// builds when None, so it must be reset here like its siblings.
self.pyro_conv = None;
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
self.pyro_last = None; self.pyro_last = None;
self.out_idx = 0; self.out_idx = 0;
@@ -1867,7 +1861,6 @@ impl IddPushCapturer {
cbcr, cbcr,
fence_handle, fence_handle,
fence_value, fence_value,
ring_gen: self.generation,
}), }),
) )
} else { } else {
@@ -1926,7 +1919,6 @@ impl IddPushCapturer {
cbcr: dst_cbcr, cbcr: dst_cbcr,
fence_handle, fence_handle,
fence_value, fence_value,
ring_gen: self.generation,
}), }),
}), }),
cursor: None, cursor: None,
+2 -10
View File
@@ -447,16 +447,8 @@ fn pump(
// every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream). // every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream).
match connector.next_frame(Duration::from_millis(20)) { match connector.next_frame(Duration::from_millis(20)) {
Ok(frame) => { Ok(frame) => {
// The `received` point: reassembly COMPLETION, stamped by the core session as // The `received` point: AU fully reassembled, in hand, before decode.
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead let received_ns = now_ns();
// 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()
};
// fps / goodput count every received AU (spec), decoded or not. // fps / goodput count every received AU (spec), decoded or not.
frames_n += 1; frames_n += 1;
bytes_n += frame.data.len() as u64; bytes_n += frame.data.len() as u64;
-57
View File
@@ -456,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 /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -532,12 +490,6 @@ pub struct Settings {
/// stores load as trackpad. /// stores load as trackpad.
#[serde(default = "default_touch_mode")] #[serde(default = "default_touch_mode")]
pub touch_mode: String, 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. /// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool, pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
@@ -625,10 +577,6 @@ fn default_touch_mode() -> String {
"trackpad".into() "trackpad".into()
} }
fn default_mouse_mode() -> String {
"capture".into()
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -656,10 +604,6 @@ impl Settings {
TouchMode::from_name(&self.touch_mode) 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). /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 { pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() { match self.codec.as_str() {
@@ -687,7 +631,6 @@ impl Default for Settings {
forward_pad: String::new(), forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
touch_mode: "trackpad".into(), touch_mode: "trackpad".into(),
mouse_mode: "capture".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
audio_channels: 2, audio_channels: 2,
+2 -42
View File
@@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W}; use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec}; use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse}; 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}; use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
@@ -29,11 +29,10 @@ enum RowId {
Pad, Pad,
PadType, PadType,
Touch, Touch,
Mouse,
Stats, Stats,
} }
const ROWS: [RowId; 14] = [ const ROWS: [RowId; 13] = [
RowId::Resolution, RowId::Resolution,
RowId::Refresh, RowId::Refresh,
RowId::Bitrate, RowId::Bitrate,
@@ -46,7 +45,6 @@ const ROWS: [RowId; 14] = [
RowId::Pad, RowId::Pad,
RowId::PadType, RowId::PadType,
RowId::Touch, RowId::Touch,
RowId::Mouse,
RowId::Stats, RowId::Stats,
]; ];
@@ -253,7 +251,6 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
"Touch mode", "Touch mode",
s.touch_mode().label().into(), s.touch_mode().label().into(),
), ),
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
RowId::Stats => ( RowId::Stats => (
Some("Interface"), Some("Interface"),
"Statistics overlay", "Statistics overlay",
@@ -295,11 +292,6 @@ fn detail(id: RowId) -> &'static str {
"How the touchscreen drives the host: Trackpad (relative cursor), \ "How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)." 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 => { RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \ "How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming." 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) step_option(cur, TouchMode::ALL.len(), delta, wrap)
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string()) .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 => { RowId::Stats => {
let cur = StatsVerbosity::ALL let cur = StatsVerbosity::ALL
.iter() .iter()
@@ -523,33 +510,6 @@ mod tests {
assert_eq!(ctx.settings.touch_mode, "trackpad"); 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] #[test]
fn unknown_value_snaps_to_first() { fn unknown_value_snaps_to_first() {
let (mut settings, pads) = ctx_parts(); let (mut settings, pads) = ctx_parts();
-5
View File
@@ -25,11 +25,6 @@ tracing = "0.1"
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`). # A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
tracing-subscriber = { version = "0.3", features = ["env-filter"] } 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] [target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms. # Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
openh264 = "0.9" openh264 = "0.9"
-101
View File
@@ -32,46 +32,6 @@ pub struct EncodedFrame {
pub chunk_aligned: bool, 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. /// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec { pub enum Codec {
@@ -259,11 +219,6 @@ pub struct EncoderCaps {
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send { 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<()>; fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the /// [`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 /// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
@@ -309,37 +264,8 @@ pub trait Encoder: Send {
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool { fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false 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. `false` from the default impl =
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
fn set_pipelined(&mut self, _on: bool) -> bool {
false
}
/// Pull the next encoded AU if one is ready. /// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>; 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 /// 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 /// 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 /// driver stops emitting AUs or accepting frames without ever returning an error). Returns
@@ -367,16 +293,6 @@ pub trait Encoder: Send {
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire. /// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly). /// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
fn set_wire_chunking(&mut self, _shard_payload: usize) {} 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) /// 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`. /// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>; fn flush(&mut self) -> Result<()>;
@@ -496,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. /// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
#[test] #[test]
fn codec_wire_roundtrip_and_label() { fn codec_wire_roundtrip_and_label() {
+3 -3
View File
@@ -826,7 +826,7 @@ impl NvencEncoder {
(*f).linesize[i] as usize, (*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 { } else if self.want_444 {
ffi::av_frame_free(&mut f); ffi::av_frame_free(&mut f);
bail!( bail!(
@@ -839,11 +839,11 @@ impl NvencEncoder {
let y_pitch = (*f).linesize[0] as usize; let y_pitch = (*f).linesize[0] as usize;
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr; let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f).linesize[1] as usize; 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 { } else {
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f).linesize[0] as usize; 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 { if let Err(e) = copy_res {
ffi::av_frame_free(&mut f); ffi::av_frame_free(&mut f);
File diff suppressed because it is too large Load Diff
+1 -18
View File
@@ -1176,24 +1176,7 @@ impl Encoder for PyroWaveEncoder {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this // SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
// struct owns and waits its own fence before touching results. // struct owns and waits its own fence before touching results.
let r = unsafe { self.encode_frame(frame) }; unsafe { self.encode_frame(frame) }
if r.is_err() {
// `encode_frame` opens the recording window early and has several fallible steps
// inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path,
// an unsupported-payload bail, and the encode call itself). Every one returns with
// `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly
// one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` —
// so the NEXT frame would call `begin` on a recording buffer, which is invalid usage.
// Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is
// not pending (we never reached the submit, or the submit itself failed).
// SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight.
unsafe {
let _ = self
.device
.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
}
}
r
} }
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
+14 -76
View File
@@ -37,11 +37,9 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
const AR24: u32 = 0x3432_5241; // ARGB8888 const AR24: u32 = 0x3432_5241; // ARGB8888
const XB24: u32 = 0x3432_4258; // XBGR8888 const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888 const AB24: u32 = 0x3432_4241; // ABGR8888
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
match fourcc { match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM), XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM), XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
_ => None, _ => None,
} }
} }
@@ -79,62 +77,21 @@ pub(crate) unsafe fn import_rgb_dmabuf(
d: &pf_frame::DmabufFrame, d: &pf_frame::DmabufFrame,
cw: u32, cw: u32,
ch: u32, ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
import_rgb_dmabuf_as(
device,
ext_fd,
mem_props,
d,
cw,
ch,
vk::ImageUsageFlags::SAMPLED,
None,
)
}
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list.
/// Despite the historical name, this also imports gamescope's one-fd LINEAR NV12: the UV
/// subresource layout comes from the producer's plane-1 chunk when it reported one, falling
/// back to the shared-stride contiguous-plane contract.
#[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn import_rgb_dmabuf_as(
device: &ash::Device,
ext_fd: &ash::khr::external_memory_fd::Device,
mem_props: &vk::PhysicalDeviceMemoryProperties,
d: &pf_frame::DmabufFrame,
cw: u32,
ch: u32,
usage: vk::ImageUsageFlags,
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
use anyhow::Context; use anyhow::Context;
use std::os::fd::IntoRawFd; use std::os::fd::IntoRawFd;
let fmt = fourcc_to_vk(d.fourcc) let fmt = fourcc_to_vk(d.fourcc)
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?; .with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM { let plane = [vk::SubresourceLayout::default()
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
d.offset as u64 + d.stride as u64 * ch as u64,
d.stride as u64,
));
vec![
vk::SubresourceLayout::default()
.offset(d.offset as u64) .offset(d.offset as u64)
.row_pitch(d.stride as u64), .row_pitch(d.stride as u64)];
vk::SubresourceLayout::default()
.offset(uv_offset)
.row_pitch(uv_stride),
]
} else {
vec![vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64)]
};
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(d.modifier) .drm_format_modifier(d.modifier)
.plane_layouts(&planes); .plane_layouts(&plane);
let mut ext = vk::ExternalMemoryImageCreateInfo::default() let mut ext = vk::ExternalMemoryImageCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let mut ci = vk::ImageCreateInfo::default() let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D) .image_type(vk::ImageType::TYPE_2D)
.format(fmt) .format(fmt)
.extent(vk::Extent3D { .extent(vk::Extent3D {
@@ -146,15 +103,13 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
.array_layers(1) .array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1) .samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
.usage(usage) .usage(vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE) .sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED) .initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut ext) .push_next(&mut ext)
.push_next(&mut drm); .push_next(&mut drm),
if let Some(pl) = profile_list { None,
ci = ci.push_next(pl); )?;
}
let img = device.create_image(&ci, None)?;
// dup the fd; Vulkan takes ownership of the dup on a successful import. // dup the fd; Vulkan takes ownership of the dup on a successful import.
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd(); let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
let fd_props = { let fd_props = {
@@ -228,8 +183,7 @@ pub(crate) unsafe fn make_plain_image(
None, None,
)?; )?;
let req = device.get_image_memory_requirements(img); let req = device.get_image_memory_requirements(img);
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple. let mem = device.allocate_memory(
let mem = match device.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(req.size) .allocation_size(req.size)
.memory_type_index(find_mem( .memory_type_index(find_mem(
@@ -238,24 +192,8 @@ pub(crate) unsafe fn make_plain_image(
vk::MemoryPropertyFlags::DEVICE_LOCAL, vk::MemoryPropertyFlags::DEVICE_LOCAL,
)), )),
None, None,
) { )?;
Ok(m) => m, device.bind_image_memory(img, mem, 0)?;
Err(e) => { let view = make_view(device, img, fmt, 0)?;
device.destroy_image(img, None); Ok((img, mem, view))
return Err(e.into());
}
};
if let Err(e) = device.bind_image_memory(img, mem, 0) {
device.destroy_image(img, None);
device.free_memory(mem, None);
return Err(e.into());
}
match make_view(device, img, fmt, 0) {
Ok(view) => Ok((img, mem, view)),
Err(e) => {
device.destroy_image(img, None);
device.free_memory(mem, None);
Err(e)
}
}
} }
@@ -1,82 +0,0 @@
//! Vendored `VK_VALVE_video_encode_rgb_conversion` bindings — the RGB→YCbCr encode-source
//! extension (Vulkan 1.4.327; RADV since Mesa 26.0, hardware-gated on the VCN EFC front-end
//! conversion block). Our pinned `ash 0.38.0+1.3.281` predates it entirely; same vendoring
//! rationale as [`vk_av1_encode`](super::vk_av1_encode) — definitions copied from the registry
//! so the layouts are correct-by-construction, chained via raw `p_next`. Consumed by
//! `vulkan_video.rs`: B0 probes + logs availability (design/vulkan-rgb-direct-encode.md);
//! B1 makes the captured BGRx dmabuf the direct encode source with EFC doing the 709-narrow CSC.
#![allow(dead_code)]
use ash::vk;
use std::ffi::{c_void, CStr};
pub const EXTENSION_NAME: &CStr = c"VK_VALVE_video_encode_rgb_conversion";
// ---------- struct-type (VkStructureType) values — construct via `stype` ----------
pub const ST_PHYSICAL_DEVICE_FEATURES: i32 = 1_000_390_000;
pub const ST_CAPABILITIES: i32 = 1_000_390_001;
pub const ST_PROFILE_INFO: i32 = 1_000_390_002;
pub const ST_SESSION_CREATE_INFO: i32 = 1_000_390_003;
// `VkVideoEncodeRgbModelConversionFlagBitsVALVE`
pub const MODEL_RGB_IDENTITY: u32 = 0x01;
pub const MODEL_YCBCR_IDENTITY: u32 = 0x02;
pub const MODEL_YCBCR_709: u32 = 0x04;
pub const MODEL_YCBCR_601: u32 = 0x08;
pub const MODEL_YCBCR_2020: u32 = 0x10;
// `VkVideoEncodeRgbRangeCompressionFlagBitsVALVE`
pub const RANGE_FULL: u32 = 0x01;
pub const RANGE_NARROW: u32 = 0x02;
// `VkVideoEncodeRgbChromaOffsetFlagBitsVALVE`
pub const CHROMA_OFFSET_COSITED_EVEN: u32 = 0x01;
pub const CHROMA_OFFSET_MIDPOINT: u32 = 0x02;
/// `VkPhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE` — chain into
/// `VkPhysicalDeviceFeatures2` (query) / `VkDeviceCreateInfo` (enable).
#[repr(C)]
pub struct PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE {
pub s_type: vk::StructureType,
pub p_next: *mut c_void,
pub video_encode_rgb_conversion: vk::Bool32,
}
/// `VkVideoEncodeRgbConversionCapabilitiesVALVE` — chain into the
/// `vkGetPhysicalDeviceVideoCapabilitiesKHR` output when the queried profile carries
/// [`VideoEncodeProfileRgbConversionInfoVALVE`]; reports which conversions the HW does.
#[repr(C)]
pub struct VideoEncodeRgbConversionCapabilitiesVALVE {
pub s_type: vk::StructureType,
pub p_next: *mut c_void,
pub rgb_models: u32,
pub rgb_ranges: u32,
pub x_chroma_offsets: u32,
pub y_chroma_offsets: u32,
}
/// `VkVideoEncodeProfileRgbConversionInfoVALVE` — part of the video-profile *identity*: every
/// consumer of the profile (caps query, format query, session, image profile lists) must carry
/// the same chain.
#[repr(C)]
pub struct VideoEncodeProfileRgbConversionInfoVALVE {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub perform_encode_rgb_conversion: vk::Bool32,
}
/// `VkVideoEncodeSessionRgbConversionCreateInfoVALVE` — chain into
/// `VkVideoSessionCreateInfoKHR`; single-bit selections of the conversion actually performed.
#[repr(C)]
pub struct VideoEncodeSessionRgbConversionCreateInfoVALVE {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub rgb_model: u32,
pub rgb_range: u32,
pub x_chroma_offset: u32,
pub y_chroma_offset: u32,
}
/// `vk::StructureType` for a raw `ST_*` constant above.
#[inline]
pub fn stype(raw: i32) -> vk::StructureType {
vk::StructureType::from_raw(raw)
}
File diff suppressed because it is too large Load Diff
-69
View File
@@ -35,38 +35,6 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
} }
} }
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
/// escape**, needed now that a backend can default higher), else the backend's
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
/// never disagree about whether a session is multi-slice.
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
if !matches!(codec, Codec::H264 | Codec::H265) {
return 1;
}
std::env::var("PUNKTFUNK_NVENC_SLICES")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| (1..=32).contains(n))
.unwrap_or(default_slices)
}
/// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions
/// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the
/// default-on escape), `1` = force (even where the caps probe says unsupported — an operator
/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its
/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`).
pub(super) fn resolve_subframe(default_on: bool) -> bool {
match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => default_on,
}
}
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated /// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each /// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames` /// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
@@ -98,9 +66,6 @@ pub(super) struct LowLatencyConfig {
pub hdr: bool, pub hdr: bool,
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery). /// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
pub rfi_supported: bool, pub rfi_supported: bool,
/// Resolved per-frame slice count ([`resolve_slices`] — env override, else the backend
/// default). ≤ 1 leaves the preset's single slice untouched.
pub slices: u32,
} }
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate) /// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
@@ -117,7 +82,6 @@ pub(super) fn build_init_params(
cfg: &mut nv::NV_ENC_CONFIG, cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32, split_mode: u32,
enable_async: bool, enable_async: bool,
subframe: bool,
) -> nv::NV_ENC_INITIALIZE_PARAMS { ) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS { let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER, version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
@@ -137,16 +101,6 @@ pub(super) fn build_init_params(
}; };
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field. // splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode); init.set_splitEncodeMode(split_mode);
// Sub-frame readback (latency plan §7 LN1; default-on for Linux direct-NVENC since Phase 3 —
// the caller resolves `subframe` via [`resolve_subframe`] + its caps probe): the driver
// writes each slice into the output buffer as it completes and reports per-slice offsets, so
// a sync-mode consumer can read slices out while the frame is still encoding. Pair with
// multi-slice (a single-slice frame yields nothing to read early). `reportSliceOffsets`
// requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
if !enable_async && subframe {
init.set_enableSubFrameWrite(1);
init.set_reportSliceOffsets(1);
}
init init
} }
@@ -164,9 +118,6 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH; cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
cfg.frameIntervalP = 1; cfg.frameIntervalP = 1;
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR; cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
// Explicit zero reorder delay: with P-only + no lookahead there is no reordering to buffer,
// but pin the bit so no preset/driver default can ever slip a frame of reorder delay in.
cfg.rcParams.set_zeroReorderDelay(1);
let bps = c.bitrate.min(u32::MAX as u64) as u32; let bps = c.bitrate.min(u32::MAX as u64) as u32;
cfg.rcParams.averageBitRate = bps; cfg.rcParams.averageBitRate = bps;
cfg.rcParams.maxBitRate = bps; cfg.rcParams.maxBitRate = bps;
@@ -195,26 +146,6 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"), Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
} }
// Multi-slice frames (latency plan §7 LN1): `c.slices` splits every frame into N slices
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
// only — AV1 partitions via tiles, not slices (the resolver already returns 1 there).
// Default 4 on Linux direct-NVENC (Phase 3), env-only elsewhere; ≤ 1 keeps the preset's
// single slice.
if let Some(n) = Some(c.slices).filter(|n| *n >= 2) {
match c.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.sliceMode = 3;
cfg.encodeCodecConfig.h264Config.sliceModeData = n;
}
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.sliceMode = 3;
cfg.encodeCodecConfig.hevcConfig.sliceModeData = n;
}
Codec::Av1 | Codec::PyroWave => {}
}
}
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT // Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3 under the FREXT
// profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma- // profile) takes precedence and composes with 10-bit (Main 4:4:4 10); it needs a full-chroma-
// capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the // capable input. Otherwise 10-bit selects Main10 (HEVC) or the AV1 output depth — stamping the
+6 -43
View File
@@ -37,8 +37,7 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe, apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
LowLatencyConfig, NvStatusExt, RFI_DPB,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -410,12 +409,6 @@ pub struct NvencD3d11Encoder {
events: Vec<usize>, events: Vec<usize>,
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve). /// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
async_rt: Option<AsyncRetrieve>, async_rt: Option<AsyncRetrieve>,
/// The capturer's `pipeline_depth` (`set_input_ring_depth`). This backend encodes the
/// capturer's textures IN PLACE, so it is a HARD ceiling on async in-flight depth: the
/// capturer rotates its ring per delivered frame regardless of encode completion, so
/// pipelining deeper lets it overwrite a texture mid-encode (torn frames). `None` until the
/// session glue reports it — treated as "unknown, don't pipeline past the env cap".
input_ring_depth: Option<usize>,
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode. /// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
async_supported: bool, async_supported: bool,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
@@ -512,7 +505,6 @@ impl NvencD3d11Encoder {
bitstreams: Vec::new(), bitstreams: Vec::new(),
events: Vec::new(), events: Vec::new(),
async_rt: None, async_rt: None,
input_ring_depth: None,
async_supported: false, async_supported: false,
pending: VecDeque::new(), pending: VecDeque::new(),
frame_idx: 0, frame_idx: 0,
@@ -745,10 +737,6 @@ impl NvencD3d11Encoder {
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 }, av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
hdr: self.hdr, hdr: self.hdr,
rfi_supported: self.rfi_supported, rfi_supported: self.rfi_supported,
// Env-only on Windows (default single slice): the Phase-3 default-on is a
// Linux-direct-NVENC decision — the Windows async path stays untouched, and a
// Windows operator opting in must choose slices+sync over async retrieve.
slices: resolve_slices(self.codec, 1),
}, },
); );
Ok(cfg) Ok(cfg)
@@ -796,9 +784,6 @@ impl NvencD3d11Encoder {
&mut cfg, &mut cfg,
split_mode, split_mode,
enable_async, enable_async,
// Windows: env opt-in only ("1"), never a default — and build_init_params
// additionally refuses it on an async session.
resolve_subframe(false),
); );
match (api().initialize_encoder)(enc, &mut init).nv_ok() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1171,21 +1156,11 @@ impl Encoder for NvencD3d11Encoder {
// index, which is non-zero on a mid-session encoder rebuild's first frame. // index, which is non-zero on a mid-session encoder rebuild's first frame.
let opening = self.next == 0; let opening = self.next == 0;
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and // Async backpressure: never hand NVENC an output bitstream that is still in flight, and
// keep in-flight depth within the capturer's texture ring. At the cap, block on the OLDEST // keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
// completion (the retrieve thread is already waiting on its event) before submitting more — // the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
// bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep // event) before submitting more — bounding depth exactly like the sync path's per-tick
// instead of 1. // blocking poll, just `cap` deep instead of 1.
// while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
// The ring term is the one that matters for correctness: `async_inflight_cap()` is only the
// output-bitstream-pool ceiling plus an env knob, and consults NOTHING about the capturer,
// despite this comment previously claiming otherwise. Since this backend encodes the
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
// rotate a texture out from under a live encode — torn frames, silently.
let cap = match self.input_ring_depth {
Some(d) => async_inflight_cap().min(d.max(1)),
None => async_inflight_cap(),
};
while self.async_rt.is_some() && self.pending.len() >= cap {
let done = { let done = {
let rt = self.async_rt.as_mut().expect("checked in loop condition"); let rt = self.async_rt.as_mut().expect("checked in loop condition");
rt.done_rx rt.done_rx
@@ -1361,17 +1336,6 @@ impl Encoder for NvencD3d11Encoder {
self.submit(frame) self.submit(frame)
} }
fn set_input_ring_depth(&mut self, depth: usize) {
// This backend registers and encodes the capturer's textures in place (no CopyResource),
// so the capturer's ring depth is a hard ceiling on how deep async may pipeline.
self.input_ring_depth = Some(depth);
tracing::debug!(
depth,
env_cap = async_inflight_cap(),
"NVENC: capturer input-ring depth reported — async in-flight bounded by the smaller"
);
}
fn request_keyframe(&mut self) { fn request_keyframe(&mut self) {
self.force_kf = true; self.force_kf = true;
} }
@@ -1576,7 +1540,6 @@ impl Encoder for NvencD3d11Encoder {
&mut cfg, &mut cfg,
self.split_mode, self.split_mode,
self.session_async, self.session_async,
resolve_subframe(false),
), ),
..Default::default() ..Default::default()
}; };
+6 -57
View File
@@ -43,9 +43,6 @@ const BS_SLACK: usize = 256 * 1024;
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed. /// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
const IMPORT_CACHE_CAP: usize = 8; const IMPORT_CACHE_CAP: usize = 8;
/// Plane-import cache key: the texture's COM address plus the extent it was imported at.
type PlaneKey = (isize, u32, u32);
// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the // --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the
// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec // pyrowave C API are generated; these plain #define / flags-typedef values are stable spec
// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32 // constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32
@@ -139,12 +136,8 @@ pub struct PyroWaveEncoder {
// Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot): // Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot):
// the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar // the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar
// NV12 import is unreliable on NVIDIA at arbitrary sizes). // NV12 import is unreliable on NVIDIA at arbitrary sizes).
/// The capturer ring generation the cached plane imports below belong to. A recreate bumps it, y_images: Vec<(isize, pw::pyrowave_image)>,
/// and every cached import is destroyed — the COM addresses they are keyed on can be recycled cbcr_images: Vec<(isize, pw::pyrowave_image)>,
/// by the allocator after a recreate, so identity cannot rest on the pointer alone.
ring_gen: Option<u32>,
y_images: Vec<(PlaneKey, pw::pyrowave_image)>,
cbcr_images: Vec<(PlaneKey, pw::pyrowave_image)>,
width: u32, width: u32,
height: u32, height: u32,
@@ -275,7 +268,6 @@ impl PyroWaveEncoder {
pw_dev, pw_dev,
pw_enc, pw_enc,
sync: std::ptr::null_mut(), sync: std::ptr::null_mut(),
ring_gen: None,
y_images: Vec::new(), y_images: Vec::new(),
cbcr_images: Vec::new(), cbcr_images: Vec::new(),
width, width,
@@ -359,16 +351,10 @@ impl PyroWaveEncoder {
/// ///
/// # Safety /// # Safety
/// Same contract as [`import_plane`]. /// Same contract as [`import_plane`].
/// Keyed on `(texture address, width, height)` rather than the bare address: the COM pointer
/// carries no reference here, so a released texture's address can be recycled by a later
/// allocation and return an import describing the WRONG surface. Folding the extent in means a
/// recycled address at a different size can never alias. (A recycle at the SAME size is still
/// possible in principle — the complete fix is to key on the capturer's ring generation, which
/// needs that generation plumbed onto `PyroFrameShare`.)
unsafe fn cached_plane( unsafe fn cached_plane(
cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>, cache: &mut Vec<(isize, pw::pyrowave_image)>,
make: impl FnOnce() -> Result<pw::pyrowave_image>, make: impl FnOnce() -> Result<pw::pyrowave_image>,
key: PlaneKey, key: isize,
) -> Result<pw::pyrowave_image> { ) -> Result<pw::pyrowave_image> {
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) { if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
return Ok(*img); return Ok(*img);
@@ -437,21 +423,6 @@ impl PyroWaveEncoder {
!self.pw_enc.is_null(), !self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)" "pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
); );
// The plane textures are imported at the encoder's CONFIGURED extent, not the frame's, so a
// capture that changed size would be read under a stale `VkImageCreateInfo`. This is
// reachable without any client Reconfigure: the IDD capturer autonomously recreates its ring
// on a confirmed display-descriptor change (e.g. a fullscreen game mode-setting the virtual
// display). Refuse instead — the session must reopen the encoder at the new mode. Mirrors
// the guard the QSV and AMF backends already carry.
anyhow::ensure!(
frame.width == self.width && frame.height == self.height,
"pyrowave: captured frame {}x{} != encoder {}x{} (the capturer recreated its ring at a \
new mode the encoder must be reopened)",
frame.width,
frame.height,
self.width,
self.height
);
let FramePayload::D3d11(d3d) = &frame.payload else { let FramePayload::D3d11(d3d) = &frame.payload else {
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)") bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
}; };
@@ -460,25 +431,6 @@ impl PyroWaveEncoder {
in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)", in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)",
)?; )?;
// Ring recreate ⇒ every cached plane import belongs to textures that no longer exist. Their
// COM addresses can be handed back out by the allocator, so a pointer-keyed hit could return
// an image bound to freed memory. Flush on the generation change rather than relying on the
// address (or the FIFO cap) to notice.
if self.ring_gen != Some(share.ring_gen) {
if self.ring_gen.is_some() {
tracing::info!(
from = ?self.ring_gen,
to = share.ring_gen,
cached = self.y_images.len() + self.cbcr_images.len(),
"pyrowave: capturer recreated its ring — flushing stale plane imports"
);
}
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
pw::pyrowave_image_destroy(img);
}
self.ring_gen = Some(share.ring_gen);
}
// Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh // Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh
// encoder after a client mode-switch rebuild (the capturer passes the persistent handle on // encoder after a client mode-switch rebuild (the capturer passes the persistent handle on
// every frame precisely so a rebuilt encoder can re-import it). // every frame precisely so a rebuilt encoder can re-import it).
@@ -513,7 +465,7 @@ impl PyroWaveEncoder {
}; };
let pw_dev = self.pw_dev; let pw_dev = self.pw_dev;
let y_img = { let y_img = {
let key = (d3d.texture.as_raw() as isize, w, h); let key = d3d.texture.as_raw() as isize;
let tex = &d3d.texture; let tex = &d3d.texture;
Self::cached_plane( Self::cached_plane(
&mut self.y_images, &mut self.y_images,
@@ -522,7 +474,7 @@ impl PyroWaveEncoder {
)? )?
}; };
let cbcr_img = { let cbcr_img = {
let key = (share.cbcr.as_raw() as isize, cw, ch); let key = share.cbcr.as_raw() as isize;
let tex = &share.cbcr; let tex = &share.cbcr;
Self::cached_plane( Self::cached_plane(
&mut self.cbcr_images, &mut self.cbcr_images,
@@ -1024,9 +976,6 @@ mod tests {
cbcr: cbcr_tex, cbcr: cbcr_tex,
fence_handle: Some(fence_handle.0 as isize), fence_handle: Some(fence_handle.0 as isize),
fence_value: 1, fence_value: 1,
// One synthetic ring for the whole case: a constant generation exercises the
// steady-state cache-hit path (a changing one would flush every frame).
ring_gen: 1,
}), }),
}), }),
cursor: None, cursor: None,
+8 -288
View File
@@ -478,14 +478,10 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
b b
}); });
// Colour signalling, written UNCONDITIONALLY (mirrors nvenc_core.rs): the input is already // HDR signalling (10-bit sessions are the HDR path on Windows — same coupling as NVENC):
// CSC'd to a specific matrix — BT.709 limited for SDR (the capture-side VideoConverter), // BT.2020/PQ colour description + the source's mastering/CLL grade at every IDR.
// BT.2020 PQ for HDR (HdrP010Converter) — so the stream must say so. An SDR stream without a
// colour description leaves the choice to the decoder's "unspecified" default, and
// Moonlight/third-party/Android-vendor decoders default to 601 at sub-HD → mis-rendered
// colours. (10-bit sessions are the HDR path on Windows — same coupling as NVENC.)
let hdr = cfg.ten_bit && cfg.codec != Codec::H264; let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
let vsi = { let vsi = hdr.then(|| {
// SAFETY: all-zero is valid; header stamped below. // SAFETY: all-zero is valid; header stamped below.
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() }); let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32; b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
@@ -493,17 +489,11 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
b.VideoFormat = 5; // unspecified b.VideoFormat = 5; // unspecified
b.VideoFullRange = 0; b.VideoFullRange = 0;
b.ColourDescriptionPresent = 1; b.ColourDescriptionPresent = 1;
if hdr {
b.ColourPrimaries = 9; // BT.2020 b.ColourPrimaries = 9; // BT.2020
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ) b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
b.MatrixCoefficients = 9; // BT.2020 non-constant b.MatrixCoefficients = 9; // BT.2020 non-constant
} else { b
b.ColourPrimaries = 1; // BT.709 });
b.TransferCharacteristics = 1; // BT.709
b.MatrixCoefficients = 1; // BT.709
}
Some(b)
};
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| { let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
// SAFETY: all-zero is valid; header stamped below. // SAFETY: all-zero is valid; header stamped below.
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> = let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
@@ -724,16 +714,7 @@ pub struct QsvEncoder {
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below. /// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
ltr_active: bool, ltr_active: bool,
/// The wire frame index stored in each LTR slot (`None` = never marked). /// The wire frame index stored in each LTR slot (`None` = never marked).
///
/// This mirrors the HARDWARE DPB, so an entry must not be cleared merely because we distrust
/// it: nulling issues no VPL call, and the encoder keeps the frame marked long-term until that
/// `LongTermIdx` is re-marked or an IDR flushes it. Distrust is recorded in `ltr_tainted`
/// instead, so the rejection list can still NAME the entry the hardware is holding.
ltr_slots: [Option<i64>; NUM_LTR_SLOTS], ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
/// Per-slot taint from `invalidate_ref_frames`' sweep: the mark is still live in the hardware
/// DPB but was encoded inside the client's corrupt window, so it may not anchor a recovery —
/// it must be REJECTED instead. Cleared wherever the slot is re-marked or the DPB is flushed.
ltr_tainted: [bool; NUM_LTR_SLOTS],
next_ltr_slot: usize, next_ltr_slot: usize,
ltr_mark_interval: i64, ltr_mark_interval: i64,
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references. /// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
@@ -808,7 +789,6 @@ impl QsvEncoder {
ir_active: false, ir_active: false,
ltr_active: false, ltr_active: false,
ltr_slots: [None; NUM_LTR_SLOTS], ltr_slots: [None; NUM_LTR_SLOTS],
ltr_tainted: [false; NUM_LTR_SLOTS],
next_ltr_slot: 0, next_ltr_slot: 0,
ltr_mark_interval: ltr_mark_interval(fps), ltr_mark_interval: ltr_mark_interval(fps),
pending_force: None, pending_force: None,
@@ -933,7 +913,6 @@ impl QsvEncoder {
self.ltr_active = ltr_active; self.ltr_active = ltr_active;
self.ir_active = ir_active; self.ir_active = ir_active;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
self.hdr_applied = self.hdr_meta; self.hdr_applied = self.hdr_meta;
@@ -1059,7 +1038,6 @@ impl Encoder for QsvEncoder {
// An IDR voids the decoder's reference buffers — drop stale slots and any // An IDR voids the decoder's reference buffers — drop stale slots and any
// queued force; the mark cadence below re-anchors on the IDR itself. // queued force; the mark cadence below re-anchors on the IDR itself.
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS]; // the IDR flushed the DPB with them
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
} else if self.ltr_test_force_at == Some(cur_idx) { } else if self.ltr_test_force_at == Some(cur_idx) {
@@ -1075,9 +1053,7 @@ impl Encoder for QsvEncoder {
// emptied the slot since the force was queued. An empty slot means there is // emptied the slot since the force was queued. An empty slot means there is
// nothing clean to re-reference — the frame must ship as a plain P WITHOUT the // nothing clean to re-reference — the frame must ship as a plain P WITHOUT the
// `recovery_anchor` tag (the client lifts its post-loss freeze on that tag). // `recovery_anchor` tag (the client lifts its post-loss freeze on that tag).
// The slot is no longer emptied by the sweep, so test the taint flag too — a if let Some(idx) = self.ltr_slots[slot] {
// tainted slot is exactly the "nothing clean to re-reference" case.
if let Some(idx) = self.ltr_slots[slot].filter(|_| !self.ltr_tainted[slot]) {
force_ltr = Some((slot, idx)); force_ltr = Some((slot, idx));
recovery_anchor = true; recovery_anchor = true;
} }
@@ -1085,9 +1061,6 @@ impl Encoder for QsvEncoder {
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) { if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
let slot = self.next_ltr_slot; let slot = self.next_ltr_slot;
self.ltr_slots[slot] = Some(cur_idx); self.ltr_slots[slot] = Some(cur_idx);
// Re-marking replaces the hardware's LongTermIdx: the tainted frame is gone from
// the DPB and this slot is clean again.
self.ltr_tainted[slot] = false;
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS; self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
mark_slot = Some(slot); mark_slot = Some(slot);
} }
@@ -1350,23 +1323,13 @@ impl Encoder for QsvEncoder {
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples // loss ships corruption as the recovery anchor, and every subsequent mark re-samples
// the soup — the sustained-loss field failure where the picture never healed. Dropped // the soup — the sustained-loss field failure where the picture never healed. Dropped
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. // slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
// for marked in self.ltr_slots.iter_mut() {
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
// could still predict from it. With two slots the "exactly one swept" case is the modal
// one, and it was the broken one.
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if marked.is_some_and(|idx| idx >= first) { if marked.is_some_and(|idx| idx >= first) {
self.ltr_tainted[slot] = true; *marked = None;
} }
} }
let mut best: Option<(usize, i64)> = None; let mut best: Option<(usize, i64)> = None;
for (slot, marked) in self.ltr_slots.iter().enumerate() { for (slot, marked) in self.ltr_slots.iter().enumerate() {
if self.ltr_tainted[slot] {
continue; // still in the DPB, but encoded inside the corrupt window
}
if let Some(idx) = *marked { if let Some(idx) = *marked {
if idx < first && best.is_none_or(|(_, b)| idx > b) { if idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx)); best = Some((slot, idx));
@@ -1491,7 +1454,6 @@ impl Encoder for QsvEncoder {
self.ltr_active = ltr; self.ltr_active = ltr;
self.ir_active = ir; self.ir_active = ir;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
if let Some(inner) = self.inner.as_mut() { if let Some(inner) = self.inner.as_mut() {
@@ -2004,246 +1966,4 @@ mod tests {
"the bitrate retarget emitted a keyframe (StartNewSequence leak)" "the bitrate retarget emitted a keyframe (StartNewSequence leak)"
); );
} }
/// FULL-CHAIN colour check at the field capture size: a known P010 colour-bar source at
/// 1920x1080 — whose height is NOT 16-aligned, so the ingest `CopySubresourceRegion` copies
/// into a 1920x1088 runtime pool surface whose chroma plane sits at a DIFFERENT row offset
/// than the source's (the seam no 640x480 test exercises) — encoded to Main10 HEVC and
/// dumped to `%TEMP%\pf_qsv_1080_bars.h265` for off-box decode verification against the
/// same codes. On-box this asserts stream shape; the pixel verdict needs a decoder.
#[test]
fn qsv_live_p010_1080_colorbars_dump() {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_P010, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
// (Y, Cb, Cr) 10-bit limited codes for the 8 sRGB bars white/yellow/cyan/green/magenta/
// red/blue/black at 80-nit SDR white under PQ/BT.2020 — the same math as pf-capture's
// `p010_reference` (and the bars_pq2020 client fixture). Stored MSB-aligned (`<<6`).
const BARS: [(u16, u16, u16); 8] = [
(490, 512, 512),
(478, 423, 518),
(464, 525, 473),
(450, 432, 476),
(350, 584, 585),
(325, 448, 598),
(226, 650, 535),
(64, 512, 512),
];
const W: u32 = 1920;
const H: u32 = 1080;
init_tracing();
let Ok((_l, impls)) = intel_loader() else {
eprintln!("skipping: no VPL loader");
return;
};
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
eprintln!("skipping: no Intel VPL implementation on this box");
return;
};
if !probe_can_encode_10bit(Codec::H265) {
eprintln!("skipping: this GPU declines 10-bit HEVC");
return;
}
// P010 initial data: plane 0 = H rows of W u16 luma; plane 1 = H/2 rows of W u16
// (interleaved Cb,Cr pairs), same pitch. Bars are vertical: bar index = x / (W/8).
let bar_w = (W / 8) as usize;
let mut init = vec![0u16; (W as usize) * (H as usize + H as usize / 2)];
for y in 0..H as usize {
for x in 0..W as usize {
init[y * W as usize + x] = BARS[(x / bar_w).min(7)].0 << 6;
}
}
let chroma_base = (W as usize) * (H as usize);
for cy in 0..(H as usize / 2) {
for cx in 0..(W as usize / 2) {
let (_, cb, cr) = BARS[((cx * 2) / bar_w).min(7)];
init[chroma_base + cy * W as usize + cx * 2] = cb << 6;
init[chroma_base + cy * W as usize + cx * 2 + 1] = cr << 6;
}
}
// SAFETY: self-contained harness on one thread/device (same contract as `drive_live`);
// the initial-data pointer outlives the synchronous CreateTexture2D that reads it.
let (device, tex) = unsafe {
let luid = windows::Win32::Foundation::LUID {
LowPart: u32::from_le_bytes(imp.luid[..4].try_into().unwrap()),
HighPart: i32::from_le_bytes(imp.luid[4..].try_into().unwrap()),
};
let factory: IDXGIFactory4 = CreateDXGIFactory1().expect("dxgi factory");
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).expect("intel adapter");
let mut device = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
windows::Win32::Foundation::HMODULE::default(),
Default::default(),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
.expect("d3d11 device on intel adapter");
let device: ID3D11Device = device.expect("device");
let 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 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
let data = D3D11_SUBRESOURCE_DATA {
pSysMem: init.as_ptr() as *const std::ffi::c_void,
SysMemPitch: W * 2,
SysMemSlicePitch: 0,
};
let mut t: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&data), Some(&mut t))
.expect("bar texture");
(device.clone(), t.expect("texture"))
};
let mut enc = QsvEncoder::open(
Codec::H265,
PixelFormat::P010,
W,
H,
30,
10_000_000,
10,
ChromaFormat::Yuv420,
)
.expect("open");
enc.set_hdr_meta(Some(test_hdr_meta()));
let mut stream = Vec::new();
let mut aus = 0usize;
let mut keyframes = 0usize;
for i in 0..12u32 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i as u64 * 33_333_333,
format: PixelFormat::P010,
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
enc.submit_indexed(&frame, i).expect("submit");
if let Some(au) = enc.poll().expect("poll") {
aus += 1;
keyframes += au.keyframe as usize;
stream.extend_from_slice(&au.data);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("drain") {
aus += 1;
keyframes += au.keyframe as usize;
stream.extend_from_slice(&au.data);
}
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
assert!(keyframes >= 1, "expected an IDR in the dump");
let path = std::env::temp_dir().join("pf_qsv_1080_bars.h265");
std::fs::write(&path, &stream).expect("write dump");
println!(
"wrote {} AUs ({} bytes, {keyframes} keyframes) to {}",
aus,
stream.len(),
path.display()
);
}
/// The PRODUCTION host chain minus the IDD ring: the REAL `HdrP010Converter` renders the 8
/// sRGB bars into a ring-profile P010 texture (`BIND_RENDER_TARGET` only — RTV-written, not
/// CPU-uploaded) on the VPL implementation's own adapter, and THAT texture goes through the
/// unaligned-height ingest copy into a Main10 encode. Dumped to
/// `%TEMP%\pf_qsv_conv_1080_bars.h265`; expected decode codes = the bars_pq2020 fixture set
/// (see `hdr_p010_convert_bars_on_luid`).
#[test]
fn qsv_live_hdr_converter_e2e_1080_dump() {
const W: u32 = 1920;
const H: u32 = 1080;
init_tracing();
let Ok((_l, impls)) = intel_loader() else {
eprintln!("skipping: no VPL loader");
return;
};
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
eprintln!("skipping: no Intel VPL implementation on this box");
return;
};
if !probe_can_encode_10bit(Codec::H265) {
eprintln!("skipping: this GPU declines 10-bit HEVC");
return;
}
let (device, tex) = pf_capture::dxgi::hdr_p010_convert_bars_on_luid(imp.luid, W, H)
.expect("converter bars");
let mut enc = QsvEncoder::open(
Codec::H265,
PixelFormat::P010,
W,
H,
30,
10_000_000,
10,
ChromaFormat::Yuv420,
)
.expect("open");
enc.set_hdr_meta(Some(test_hdr_meta()));
let mut stream = Vec::new();
let mut aus = 0usize;
for i in 0..12u32 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i as u64 * 33_333_333,
format: PixelFormat::P010,
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
enc.submit_indexed(&frame, i).expect("submit");
if let Some(au) = enc.poll().expect("poll") {
aus += 1;
stream.extend_from_slice(&au.data);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("drain") {
aus += 1;
stream.extend_from_slice(&au.data);
}
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
let path = std::env::temp_dir().join("pf_qsv_conv_1080_bars.h265");
std::fs::write(&path, &stream).expect("write dump");
println!(
"wrote {aus} AUs ({} bytes) to {}",
stream.len(),
path.display()
);
}
} }
+3 -96
View File
@@ -129,9 +129,6 @@ pub fn open_video(
cuda: bool, cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
cursor_blend: bool,
) -> Result<Box<dyn Encoder>> { ) -> Result<Box<dyn Encoder>> {
let (inner, backend) = open_video_backend( let (inner, backend) = open_video_backend(
codec, codec,
@@ -143,7 +140,6 @@ pub fn open_video(
cuda, cuda,
bit_depth, bit_depth,
chroma, chroma,
cursor_blend,
)?; )?;
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label // Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by // is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
@@ -207,34 +203,15 @@ impl Encoder for TrackedEncoder {
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool { fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
self.inner.invalidate_ref_frames(first_frame, last_frame) self.inner.invalidate_ref_frames(first_frame, last_frame)
} }
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
// escalation for every session, since the host loop only ever holds the wrapped box.
fn set_pipelined(&mut self, on: bool) -> bool {
self.inner.set_pipelined(on)
}
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded // The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the // silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
// §4.4 chunking probe run hit the default while the plan said Some(1408)). // §4.4 chunking probe run hit the default while the plan said Some(1408)).
fn set_wire_chunking(&mut self, shard_payload: usize) { fn set_wire_chunking(&mut self, shard_payload: usize) {
self.inner.set_wire_chunking(shard_payload) self.inner.set_wire_chunking(shard_payload)
} }
// Forwarded for the same reason as `set_wire_chunking` above — an unforwarded default here
// would silently leave the in-place backends pipelining past the capturer's ring.
fn set_input_ring_depth(&mut self, depth: usize) {
self.inner.set_input_ring_depth(depth)
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll() self.inner.poll()
} }
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
fn supports_chunked_poll(&self) -> bool {
self.inner.supports_chunked_poll()
}
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
self.inner.poll_chunk()
}
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
self.inner.reset() self.inner.reset()
} }
@@ -268,9 +245,7 @@ fn open_video_backend(
cuda: bool, cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
cursor_blend: bool,
) -> Result<(Box<dyn Encoder>, &'static str)> { ) -> Result<(Box<dyn Encoder>, &'static str)> {
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
validate_dimensions(codec, width, height)?; validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`) // Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero. // and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
@@ -328,15 +303,8 @@ fn open_video_backend(
&& vulkan_encode_enabled() && vulkan_encode_enabled()
&& !(bit_depth == 10 && format.is_hdr_rgb10()) && !(bit_depth == 10 && format.is_hdr_rgb10())
{ {
match vulkan_video::VulkanVideoEncoder::open( match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
codec, {
format,
width,
height,
fps,
bitrate_bps,
cursor_blend,
) {
Ok(e) => { Ok(e) => {
tracing::info!( tracing::info!(
codec = ?codec, codec = ?codec,
@@ -345,32 +313,12 @@ fn open_video_backend(
); );
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan")); return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
} }
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
// libav's dmabuf lane would import the two-plane buffer as packed RGB
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
Err(e) if format == PixelFormat::Nv12 => {
return Err(e.context(
"Vulkan Video open failed on a native-NV12 capture \
no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
restore the packed-RGB negotiation",
));
}
Err(e) => tracing::warn!( Err(e) => tracing::warn!(
error = %format!("{e:#}"), error = %format!("{e:#}"),
"Vulkan Video encode open failed — falling back to libav VAAPI" "Vulkan Video encode open failed — falling back to libav VAAPI"
), ),
} }
} }
// Same rule when the Vulkan backend was never eligible (H264 session,
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
if format == PixelFormat::Nv12 {
anyhow::bail!(
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) this \
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
the packed-RGB negotiation"
);
}
vaapi::VaapiEncoder::open( vaapi::VaapiEncoder::open(
codec, codec,
format, format,
@@ -409,15 +357,7 @@ fn open_video_backend(
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}" "the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
); );
} }
vulkan_video::VulkanVideoEncoder::open( vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
codec,
format,
width,
height,
fps,
bitrate_bps,
cursor_blend,
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan")) .map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
} }
#[cfg(not(feature = "vulkan-encode"))] #[cfg(not(feature = "vulkan-encode"))]
@@ -841,33 +781,6 @@ fn vulkan_encode_enabled() -> bool {
.unwrap_or(true) .unwrap_or(true)
} }
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
/// the backend must be eligible to open. The host facade threads the verdict into the capture
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
/// escapes at the capture gate).
#[cfg(target_os = "linux")]
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
#[cfg(feature = "vulkan-encode")]
{
matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
&& !matches!(
pf_host_config::config().encoder_pref.as_str(),
"nvenc" | "nvidia" | "cuda" | "pyrowave"
)
}
#[cfg(not(feature = "vulkan-encode"))]
{
let _ = codec;
false
}
}
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA /// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT /// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be /// create a CUDA context (that would allocate GPU state on every host that merely *might* be
@@ -1425,12 +1338,6 @@ mod vulkan_video;
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))] #[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
#[path = "enc/linux/vk_av1_encode.rs"] #[path = "enc/linux/vk_av1_encode.rs"]
mod vk_av1_encode; mod vk_av1_encode;
// Vendored `VK_VALVE_video_encode_rgb_conversion` bindings (host-only) — RGB encode source with
// the VCN EFC front-end doing the CSC (design/vulkan-rgb-direct-encode.md). ash 0.38 predates
// the extension; same vendoring rationale as `vk_av1_encode`.
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
#[path = "enc/linux/vk_valve_rgb.rs"]
mod vk_valve_rgb;
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory // Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived. // utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
#[cfg(all( #[cfg(all(
-6
View File
@@ -52,12 +52,6 @@ pub struct PyroFrameShare {
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan /// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC. /// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
pub fence_value: u64, pub fence_value: u64,
/// The capturer's ring generation, bumped every time it recreates its texture ring. The
/// PyroWave encoder caches its plane imports keyed on the texture's COM address, which carries
/// no reference — after a recreate those addresses can be recycled by the allocator, so a
/// cached import may describe a texture that no longer exists. The encoder flushes its import
/// cache whenever this changes, making cache identity independent of allocator behaviour.
pub ring_gen: u32,
} }
/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place; /// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place;
+10 -31
View File
@@ -105,15 +105,13 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888 Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888 Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888 Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
// Linux native NV12 capture (gamescope PipeWire): one LINEAR dmabuf with contiguous Y then
// interleaved UV, exposed under DRM_FORMAT_NV12.
Nv12 => drm_fourcc_code(b"NV12"),
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020). // The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010 X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010 X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path. // 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
// Rgb10a2/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source. // Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None, // Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
}) })
} }
@@ -146,12 +144,6 @@ pub struct OutputFormat {
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the /// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture). /// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
pub pyrowave: bool, pub pyrowave: bool,
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
pub nv12_native: bool,
} }
impl OutputFormat { impl OutputFormat {
@@ -169,11 +161,6 @@ impl OutputFormat {
chroma_444: false, chroma_444: false,
// GameStream never negotiates PyroWave (native punktfunk/1 only). // GameStream never negotiates PyroWave (native punktfunk/1 only).
pyrowave: false, pyrowave: false,
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
// `SessionPlan::output_format()`, which knows the codec.
nv12_native: false,
} }
} }
} }
@@ -214,26 +201,18 @@ pub struct CapturedFrame {
pub cursor: Option<CursorOverlay>, pub cursor: Option<CursorOverlay>,
} }
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12 /// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
/// fallback `offset + stride * frame_height` with the shared `stride`.
///
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be /// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
/// imported there without the compositor's buffer being closed underneath it. Content stability /// imported into a VA surface there without the compositor's buffer being closed underneath it.
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy /// (Content stability across the brief import window relies on the compositor's buffer pool depth,
/// capture. /// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub struct DmabufFrame { pub struct DmabufFrame {
pub fd: std::os::fd::OwnedFd, pub fd: std::os::fd::OwnedFd,
/// DRM FourCC (`XR24` for BGRx, `NV12` for native 4:2:0). /// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
pub fourcc: u32, pub fourcc: u32,
/// DRM format modifier the compositor allocated (0 = LINEAR). /// DRM format modifier the compositor allocated (0 = LINEAR).
pub modifier: u64, pub modifier: u64,
/// Second-plane `(offset, stride)` within the SAME fd, when the producer reported one (the
/// PipeWire buffer's plane-1 chunk — NV12's interleaved UV). `None` falls back to the
/// contiguous-plane contract above. Always `None` for single-plane packed RGB.
pub plane1: Option<(u32, u32)>,
pub offset: u32, pub offset: u32,
pub stride: u32, pub stride: u32,
} }
@@ -246,8 +225,8 @@ pub enum FramePayload {
/// The dmabuf has already been imported + copied into this owned device buffer. /// The dmabuf has already been imported + copied into this owned device buffer.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Cuda(pf_zerocopy::DeviceBuffer), Cuda(pf_zerocopy::DeviceBuffer),
/// A raw DMA-BUF: packed RGB for the existing GPU CSC paths, or native NV12 from a producer /// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
/// such as gamescope. The encoder imports it without a host copy. /// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Dmabuf(DmabufFrame), Dmabuf(DmabufFrame),
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame. /// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
-17
View File
@@ -63,13 +63,6 @@ pub struct HostConfig {
/// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0. /// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0.
/// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth). /// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth).
pub four_four_four: bool, pub four_four_four: bool,
/// `PUNKTFUNK_CHACHA20` — host policy gate for the negotiated ChaCha20-Poly1305 session
/// cipher (design/chacha20-session-cipher.md). **Default ON** (pure rollout safety — perf-only,
/// both AEADs are full-strength): the host merely *allows* it — a session only seals with
/// ChaCha when the client advertised `VIDEO_CAP_CHACHA20` (set by soft-AES armv7 clients,
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
pub chacha20: bool,
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation. /// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
pub perf: bool, pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic). /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
@@ -154,16 +147,6 @@ impl HostConfig {
) )
}) })
.unwrap_or(true), .unwrap_or(true),
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc).
chacha20: val("PUNKTFUNK_CHACHA20")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"), perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
compositor: val("PUNKTFUNK_COMPOSITOR"), compositor: val("PUNKTFUNK_COMPOSITOR"),
@@ -17,7 +17,7 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::mem::size_of; use std::mem::size_of;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
@@ -196,45 +196,6 @@ impl Drop for GadgetFd {
} }
} }
/// The signal used to break a worker thread out of a blocking raw_gadget ioctl at teardown.
/// `EVENT_FETCH`/`EP_WRITE` are `wait_event_interruptible` in the kernel with no timeout and no
/// `O_NONBLOCK` honouring, and closing the fd cannot wake a thread already inside the ioctl (the
/// in-flight syscall holds a reference to the struct file). A signal is the only reliable lever:
/// delivered with a no-op, non-`SA_RESTART` handler it forces the ioctl to return `EINTR`, after
/// which the loop's top-of-iteration `running` check exits. `SIGUSR1` is unused elsewhere in this
/// process; the handler is a no-op, so a stray `SIGUSR1` becomes harmless rather than fatal.
const WAKE_SIGNAL: libc::c_int = libc::SIGUSR1;
/// Install the no-op `WAKE_SIGNAL` handler exactly once. Crucially `sa_flags = 0` (no `SA_RESTART`)
/// so a delivered signal makes the interruptible ioctl return `EINTR` instead of auto-restarting.
fn install_wake_handler() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
extern "C" fn noop(_: libc::c_int) {}
// SAFETY: installing a well-formed `sigaction` with an empty mask and a valid no-op handler
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
// Via `*const ()`: casting a function item straight to an integer is what
// `clippy::function_casts_as_integer` rejects, and the pointer hop is the documented
// way to spell it. `sa_sigaction` is a `usize`-typed handler slot, so the value is
// unchanged.
sa.sa_sigaction = noop as *const () as usize;
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_flags = 0;
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
}
});
}
/// Lets `Drop` wake a specific worker thread parked in a blocking ioctl. `tid` is the thread's
/// `pthread_self()` (0 until it starts); `done` is set right before the thread returns, so `Drop`
/// stops signalling a thread that has already exited.
struct Waker {
tid: Arc<AtomicU64>,
done: Arc<AtomicBool>,
}
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and /// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
/// closes the gadget (the kernel tears down the device). /// closes the gadget (the kernel tears down the device).
pub struct SteamDeckGadget { pub struct SteamDeckGadget {
@@ -242,7 +203,6 @@ pub struct SteamDeckGadget {
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>, feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>, threads: Vec<JoinHandle<()>>,
wakers: Vec<Waker>,
_fd: Arc<GadgetFd>, _fd: Arc<GadgetFd>,
seq: u32, seq: u32,
} }
@@ -283,18 +243,6 @@ impl SteamDeckGadget {
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1)); let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
let configured = Arc::new(AtomicBool::new(false)); let configured = Arc::new(AtomicBool::new(false));
// The teardown wake path (see `WAKE_SIGNAL`) needs the handler installed before any thread
// can park in a blocking ioctl.
install_wake_handler();
let ctrl_waker = Waker {
tid: Arc::new(AtomicU64::new(0)),
done: Arc::new(AtomicBool::new(false)),
};
let stream_waker = Waker {
tid: Arc::new(AtomicU64::new(0)),
done: Arc::new(AtomicBool::new(false)),
};
// Control thread: enumerate + answer every control transfer. // Control thread: enumerate + answer every control transfer.
let control = { let control = {
let fd = fd.clone(); let fd = fd.clone();
@@ -302,15 +250,10 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone(); let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone(); let configured = configured.clone();
let feedback = feedback.clone(); let feedback = feedback.clone();
let tid = ctrl_waker.tid.clone();
let done = ctrl_waker.done.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-deck-gadget-ctrl".into()) .name("pf-deck-gadget-ctrl".into())
.spawn(move || { .spawn(move || {
// SAFETY: `pthread_self` is always valid on the calling thread. control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id);
done.store(true, Ordering::SeqCst);
}) })
.context("spawn gadget control thread")? .context("spawn gadget control thread")?
}; };
@@ -321,16 +264,9 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone(); let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone(); let configured = configured.clone();
let report = report.clone(); let report = report.clone();
let tid = stream_waker.tid.clone();
let done = stream_waker.done.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-deck-gadget-stream".into()) .name("pf-deck-gadget-stream".into())
.spawn(move || { .spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
// SAFETY: `pthread_self` is always valid on the calling thread.
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
stream_loop(fd, running, ctrl_ep, configured, report);
done.store(true, Ordering::SeqCst);
})
.context("spawn gadget stream thread")? .context("spawn gadget stream thread")?
}; };
@@ -339,7 +275,6 @@ impl SteamDeckGadget {
feedback, feedback,
running, running,
threads: vec![control, stream], threads: vec![control, stream],
wakers: vec![ctrl_waker, stream_waker],
_fd: fd, _fd: fd,
seq: 0, seq: 0,
}) })
@@ -367,32 +302,6 @@ impl SteamDeckGadget {
impl Drop for SteamDeckGadget { impl Drop for SteamDeckGadget {
fn drop(&mut self) { fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst); self.running.store(false, Ordering::SeqCst);
// The control thread spends steady state parked in a blocking `EVENT_FETCH` ioctl that only
// tests `running` at the top of its loop, so clearing the flag is not enough — it must be
// signalled out of the syscall (see `WAKE_SIGNAL`). Without this the join below can hang the
// caller (the session input thread, via `PadSlots::sweep`) indefinitely. Retry until each
// thread reports done, to cover the race where the signal lands just before the thread
// re-enters the ioctl; bounded (~1 s) so a genuinely stuck thread can't wedge teardown either.
for _ in 0..200 {
let mut all_done = true;
for w in &self.wakers {
if w.done.load(Ordering::SeqCst) {
continue;
}
all_done = false;
let tid = w.tid.load(Ordering::SeqCst);
if tid != 0 {
// SAFETY: the thread is joinable and not yet joined (join runs after this loop),
// so `tid` names a live pthread; `pthread_kill` on a finished-but-unjoined thread
// is defined (returns ESRCH), never UB.
unsafe { libc::pthread_kill(tid as libc::pthread_t, WAKE_SIGNAL) };
}
}
if all_done {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
for t in self.threads.drain(..) { for t in self.threads.drain(..) {
let _ = t.join(); let _ = t.join();
} }
@@ -299,20 +299,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
// would read as success and mask the failure (found by the 2026-07 driver-health audit). // would read as success and mask the failure (found by the 2026-07 driver-health audit).
// HEAP-allocated, deliberately: `sw_create_cb` writes `result` + up to 127 u16 of instance id let mut ctx = SwCreateCtx {
// through this pointer and then `SetEvent`s. The wait below is bounded (10 s), so on a wedged-PnP
// timeout the callback may still be PENDING — a stack context would be popped and a late callback
// would corrupt whatever the input thread put there next, and SetEvent a closed/recycled handle.
// On the timeout path we therefore LEAK the box and leave the event open (a one-off ~264 B + one
// HANDLE, only on that rare path) so a late callback always writes to live memory.
let ctx = Box::into_raw(Box::new(SwCreateCtx {
event, event,
result: E_FAIL, result: E_FAIL,
instance_id: [0; 128], instance_id: [0; 128],
})); };
// SAFETY: info + the buffers outlive the call; `ctx` is a live heap allocation that outlives every // SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
// path below (reclaimed only where the callback provably ran). windows-rs returns the HSWDEVICE // windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
// (the C out-param) as the Result value.
let hsw = match unsafe { let hsw = match unsafe {
SwDeviceCreate( SwDeviceCreate(
w!("punktfunk"), w!("punktfunk"),
@@ -320,15 +313,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
&info, &info,
None, None,
Some(sw_create_cb), Some(sw_create_cb),
Some(ctx as *const c_void), Some(&mut ctx as *mut SwCreateCtx as *const c_void),
) )
} { } {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim; // SAFETY: event is valid.
// `event` is valid and unreferenced.
unsafe { unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event); let _ = CloseHandle(event);
} }
return Err(anyhow!("SwDeviceCreate failed: {e}")); return Err(anyhow!("SwDeviceCreate failed: {e}"));
@@ -337,22 +328,17 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
// Block until PnP finishes enumerating (the callback signals), then check its result. // Block until PnP finishes enumerating (the callback signals), then check its result.
// SAFETY: event is valid. // SAFETY: event is valid.
let wait = unsafe { WaitForSingleObject(event, 10_000) }; let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
if wait != WAIT_OBJECT_0 { if wait != WAIT_OBJECT_0 {
// Timed out: the callback may still fire. Intentionally leak `ctx` AND leave `event` open so
// its eventual write + SetEvent target live memory/handle rather than freed ones.
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
return Err(anyhow!( return Err(anyhow!(
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged" "SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
)); ));
} }
// The callback ran (it is what signalled the event), so nothing else will touch `ctx`/`event`.
// SAFETY: `ctx` came from `Box::into_raw` above and is reclaimed exactly once here; `event` is
// valid and no longer referenced by a pending callback.
let ctx = unsafe {
let _ = CloseHandle(event);
Box::from_raw(ctx)
};
if ctx.result.is_err() { if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
@@ -62,7 +62,7 @@ impl Ds4WinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let inst = format!("pf_ds4_{index}"); let inst = format!("pf_ds4_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_tag: 0x5046_4453, // "PFDS" container_tag: 0x5046_4453, // "PFDS"
container_index: index, container_index: index,
@@ -70,13 +70,13 @@ impl Ds4WinPad {
usb_vid_pid: "VID_054C&PID_09CC", usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None, usb_mi: None,
description: "punktfunk Virtual DualShock 4", description: "punktfunk Virtual DualShock 4",
})?; // Propagate, do NOT swallow — see below. }) {
let (hsw, instance_id) = (Some(hsw), instance_id); Ok((h, id)) => (Some(h), id),
// Swallowing a create failure here (the previous behaviour) latched the pad slot to Err(e) => {
// `Some(pad)` with no live devnode: `PadSlots::ensure` short-circuits on `is_some()` and tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to (None, None)
// self-heal a transient PnP failure never retried. The game saw no controller for the whole }
// session unless the client unplugged the pad. Matches the XUSB sibling, which propagates. };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver // Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for // must read `device_type = 1` from the delivered DATA section before hidclass asks it for
@@ -82,16 +82,12 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
// would read as success and mask the failure (found by the 2026-07 driver-health audit). // would read as success and mask the failure (found by the 2026-07 driver-health audit).
// HEAP-allocated for the same reason as the DualSense sibling: the callback writes through this let mut ctx = SwCreateCtx {
// pointer and SetEvents, and the wait below is bounded — a stack context would be popped while a
// late callback still holds it. On the timeout path the box is deliberately leaked and the event
// left open so a late write/SetEvent always targets live memory/handle.
let ctx = Box::into_raw(Box::new(SwCreateCtx {
event, event,
result: E_FAIL, result: E_FAIL,
instance_id: [0; 128], instance_id: [0; 128],
})); };
// SAFETY: info + buffers outlive the call; `ctx` is a live heap allocation outliving every path. // SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
let hsw = match unsafe { let hsw = match unsafe {
SwDeviceCreate( SwDeviceCreate(
w!("punktfunk"), w!("punktfunk"),
@@ -99,14 +95,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
&info, &info,
None, None,
Some(sw_create_cb), Some(sw_create_cb),
Some(ctx as *const c_void), Some(&mut ctx as *mut SwCreateCtx as *const c_void),
) )
} { } {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim. // SAFETY: event is valid.
unsafe { unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event); let _ = CloseHandle(event);
} }
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}")); return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
@@ -114,20 +109,17 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
}; };
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result. // SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
let wait = unsafe { WaitForSingleObject(event, 10_000) }; let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
if wait != WAIT_OBJECT_0 { if wait != WAIT_OBJECT_0 {
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
return Err(anyhow!( return Err(anyhow!(
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged" "SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
)); ));
} }
// The callback ran (it signalled the event), so nothing else will touch `ctx`/`event`.
// SAFETY: `ctx` came from `Box::into_raw` and is reclaimed exactly once here.
let ctx = unsafe {
let _ = CloseHandle(event);
Box::from_raw(ctx)
};
if ctx.result.is_err() { if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
@@ -66,7 +66,7 @@ impl DeckWinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let inst = format!("pf_deck_{index}"); let inst = format!("pf_deck_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_tag: 0x5046_4453, // "PFDS" container_tag: 0x5046_4453, // "PFDS"
container_index: index, container_index: index,
@@ -77,8 +77,13 @@ impl DeckWinPad {
// spike's run-1 failure). // spike's run-1 failure).
usb_mi: Some(2), usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck", description: "punktfunk Virtual Steam Deck",
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). }) {
let (hsw, instance_id) = (Some(hsw), instance_id); Ok((h, i)) => (Some(h), i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
(None, None)
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks // Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
// it for descriptors, or the pad would enumerate with the default DualSense identity. // it for descriptors, or the pad would enumerate with the default DualSense identity.
+1 -5
View File
@@ -11,11 +11,7 @@ repository.workspace = true
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one # Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
# Linux-only module — see lib.rs). # Linux-only module — see lib.rs).
[target.'cfg(any(target_os = "linux", windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own pf-client-core = { path = "../pf-client-core" }
# `pyrowave` feature (which re-exports it below), never by inheriting the dependency's default.
# Otherwise a consumer that deliberately builds us without `pyrowave` still drags the vendored
# C++ in — fatal on Windows ARM64, where Granite has no SIMD path.
pf-client-core = { path = "../pf-client-core", default-features = false }
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock). # AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
pf-ffvk = { path = "../pf-ffvk" } pf-ffvk = { path = "../pf-ffvk" }
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
+3 -63
View File
@@ -14,17 +14,10 @@
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are //! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would //! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
//! otherwise send a datagram per event). //! otherwise send a datagram per event).
//!
//! The DESKTOP mouse model (design/remote-desktop-sweep.md M1) reuses this same engage/
//! release state but never locks the pointer: the local cursor moves freely (hidden over
//! the window — the host's composited cursor is the one you see) and motion goes on the
//! wire as absolute positions through the letterbox (`MouseMoveAbs`, latest-wins per loop
//! iteration). Requires a host injector with absolute support — gamescope's EIS is
//! relative-only, so sessions there are pinned to capture ([`Capture::new`] `abs_ok`).
use crate::keymap_sdl; use crate::keymap_sdl;
use crate::touch::{Abs, Act, Gestures}; use crate::touch::{Abs, Act, Gestures};
use pf_client_core::trust::{MouseMode, TouchMode}; use pf_client_core::trust::TouchMode;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -48,13 +41,6 @@ pub struct Capture {
held_buttons: HashSet<u32>, held_buttons: HashSet<u32>,
/// Relative motion not yet on the wire, summed per loop iteration. /// Relative motion not yet on the wire, summed per loop iteration.
pending_rel: (i32, i32), pending_rel: (i32, i32),
/// Desktop-model position not yet on the wire, latest-wins per loop iteration.
pending_abs: Option<Abs>,
/// The desktop (absolute, uncaptured) mouse model is active. Flipped live by the
/// Ctrl+Alt+Shift+M chord; never true unless `abs_ok`.
desktop: bool,
/// The host injector accepts `MouseMoveAbs` (any compositor but gamescope).
abs_ok: bool,
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space — /// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail. /// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
scroll_acc: (f64, f64), scroll_acc: (f64, f64),
@@ -84,14 +70,10 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
} }
impl Capture { impl Capture {
/// `abs_ok` = the host injector accepts absolute pointer events; without it the
/// desktop model is unavailable and `mouse_mode` silently resolves to capture.
pub fn new( pub fn new(
connector: Arc<NativeClient>, connector: Arc<NativeClient>,
touch_mode: TouchMode, touch_mode: TouchMode,
invert_scroll: bool, invert_scroll: bool,
mouse_mode: MouseMode,
abs_ok: bool,
) -> Capture { ) -> Capture {
Capture { Capture {
connector, connector,
@@ -100,9 +82,6 @@ impl Capture {
held_keys: HashSet::new(), held_keys: HashSet::new(),
held_buttons: HashSet::new(), held_buttons: HashSet::new(),
pending_rel: (0, 0), pending_rel: (0, 0),
pending_abs: None,
desktop: abs_ok && mouse_mode == MouseMode::Desktop,
abs_ok,
scroll_acc: (0.0, 0.0), scroll_acc: (0.0, 0.0),
touch_slots: HashMap::new(), touch_slots: HashMap::new(),
touch_mode, touch_mode,
@@ -115,24 +94,6 @@ impl Capture {
self.captured self.captured
} }
/// The desktop (absolute, uncaptured) mouse model is active.
pub fn desktop(&self) -> bool {
self.desktop
}
/// Flip capture ⇄ desktop (the Ctrl+Alt+Shift+M chord). `None` = the host can't take
/// absolute pointer events (gamescope), so the chord has nothing to offer; otherwise
/// the new desktop state. Motion gathered under the old model never crosses modes.
pub fn toggle_desktop(&mut self) -> Option<bool> {
if !self.abs_ok {
return None;
}
self.desktop = !self.desktop;
self.pending_rel = (0, 0);
self.pending_abs = None;
Some(self.desktop)
}
/// Whether a regained focus should re-engage: yes unless the user released /// Whether a regained focus should re-engage: yes unless the user released
/// deliberately (the chord keeps its meaning across an Alt-Tab). /// deliberately (the chord keeps its meaning across an Alt-Tab).
pub fn should_reengage(&self) -> bool { pub fn should_reengage(&self) -> bool {
@@ -156,7 +117,6 @@ impl Capture {
return false; return false;
} }
self.pending_rel = (0, 0); // never flush motion gathered while captured self.pending_rel = (0, 0); // never flush motion gathered while captured
self.pending_abs = None;
for vk in self.held_keys.drain() { for vk in self.held_keys.drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
} }
@@ -172,42 +132,22 @@ impl Capture {
true true
} }
/// Forward the coalesced motion, if any — one datagram per loop iteration. Only one /// Forward the coalesced motion delta, if any — one datagram per loop iteration.
/// of the two stores is ever populated (the run loop routes by [`desktop`](Self::desktop)).
pub fn flush_motion(&mut self) { pub fn flush_motion(&mut self) {
let (dx, dy) = std::mem::take(&mut self.pending_rel); let (dx, dy) = std::mem::take(&mut self.pending_rel);
if dx != 0 || dy != 0 { if dx != 0 || dy != 0 {
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0); send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
} }
if let Some(a) = self.pending_abs.take() {
send(
&self.connector,
InputKind::MouseMoveAbs,
0,
a.x,
a.y,
Self::touch_flags(a.w, a.h),
);
}
} }
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked). /// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
pub fn on_motion(&mut self, xrel: f32, yrel: f32) { pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
if self.captured && !self.desktop { if self.captured {
self.pending_rel.0 += xrel as i32; self.pending_rel.0 += xrel as i32;
self.pending_rel.1 += yrel as i32; self.pending_rel.1 += yrel as i32;
} }
} }
/// Desktop-model motion: the cursor's position mapped into the letterboxed content
/// rect. Latest-wins — intermediate positions carry no information the final one
/// doesn't (unlike deltas, which must sum).
pub fn on_motion_abs(&mut self, abs: Abs) {
if self.captured && self.desktop {
self.pending_abs = Some(abs);
}
}
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) { pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) {
if !self.captured { if !self.captured {
return; return;
+20 -102
View File
@@ -20,11 +20,11 @@ use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService; use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use pf_client_core::trust::{StatsVerbosity, TouchMode};
use pf_client_core::video::VulkanDecodeDevice; use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage}; use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, Mode}; use punktfunk_core::config::Mode;
use sdl3::event::{Event, WindowEvent}; use sdl3::event::{Event, WindowEvent};
use sdl3::keyboard::Mod; use sdl3::keyboard::Mod;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -48,11 +48,6 @@ pub struct SessionOpts {
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per /// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
/// session — a mouse-only client leaves this at the default and never sees a finger. /// session — a mouse-only client leaves this at the default and never sees a finger.
pub touch_mode: TouchMode, pub touch_mode: TouchMode,
/// Physical-mouse model: `Capture` (pointer lock + relative, the default) or `Desktop`
/// (uncaptured absolute pointer — design/remote-desktop-sweep.md M1). Ctrl+Alt+Shift+M
/// flips it live; silently resolves to capture on hosts without absolute injection
/// (gamescope).
pub mouse_mode: MouseMode,
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]). /// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
pub invert_scroll: bool, pub invert_scroll: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame. /// Emit the `{"ready":true}` stdout line after the first presented frame.
@@ -338,11 +333,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven // bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
// and consumes no mouse, so nothing wanted these synthetic events anyway. // and consumes no mouse, so nothing wanted these synthetic events anyway.
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0"); sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
// The Wayland `app_id` (and X11 WM_CLASS) — compositors match it against
// io.unom.Punktfunk.desktop for the window/taskbar icon. Without it SDL uses a generic
// identity and the session window gets the default-Wayland icon (the Linux analog of
// the AppUserModelID adoption above).
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
let sdl = sdl3::init().context("SDL init")?; let sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?; let video = sdl.video().context("SDL video")?;
let events = sdl.event().context("SDL events")?; let events = sdl.event().context("SDL events")?;
@@ -495,7 +485,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
WindowEvent::FocusLost => { WindowEvent::FocusLost => {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.release(false) { if cap.release(false) {
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
tracing::info!("focus lost — input released"); tracing::info!("focus lost — input released");
} }
} }
@@ -506,7 +496,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.should_reengage() { if cap.should_reengage() {
cap.engage(); cap.engage();
apply_capture(&mut window, &mouse, true, cap.desktop()); apply_capture(&mut window, &mouse, true);
tracing::info!("focus gained — input recaptured"); tracing::info!("focus gained — input recaptured");
} }
} }
@@ -542,39 +532,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.captured() { if cap.captured() {
cap.release(true); cap.release(true);
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
} else { } else {
cap.engage(); cap.engage();
apply_capture(&mut window, &mouse, true, cap.desktop()); apply_capture(&mut window, &mouse, true);
} }
tracing::info!(captured = cap.captured(), "chord: release/engage"); tracing::info!(captured = cap.captured(), "chord: release/engage");
} }
continue; continue;
} }
// Mouse model flip (capture ⇄ desktop) — applies immediately when
// engaged; a released stream just changes what the next engage does.
if chord && sc == Scancode::M {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
match cap.toggle_desktop() {
Some(desktop) => {
if cap.captured() {
apply_capture(&mut window, &mouse, true, desktop);
}
tracing::info!(desktop, "chord: mouse mode");
}
None => tracing::info!(
"chord: mouse mode — host has no absolute pointer \
(gamescope), staying captured"
),
}
}
continue;
}
if chord && sc == Scancode::D { if chord && sc == Scancode::D {
if let Some(st) = &mut stream { if let Some(st) = &mut stream {
tracing::info!("chord: disconnect"); tracing::info!("chord: disconnect");
st.request_quit(); st.request_quit();
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
// The pump emits Ended(None); the end path routes per mode. // The pump emits Ended(None); the end path routes per mode.
} }
continue; continue;
@@ -607,42 +578,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
cap.on_key_up(sc); cap.on_key_up(sc);
} }
} }
Event::MouseMotion { Event::MouseMotion { xrel, yrel, .. } => {
x, y, xrel, yrel, .. if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
} => {
if let Some(st) = stream.as_mut() {
let video = st.last_video;
if let Some(cap) = st.capture.as_mut() {
if cap.desktop() {
// Desktop model: the cursor's window position through the
// letterbox (same mapping as a pointer-mode finger).
// Before the first decoded frame there is nothing to map
// onto — dropped, like touch.
if let Some(video) = video {
let (lw, lh) = window.size();
let nx = x / lw.max(1) as f32;
let ny = y / lh.max(1) as f32;
let (ax, ay, aw, ah) =
finger_to_content(window.size_in_pixels(), video, nx, ny);
cap.on_motion_abs(Abs {
x: ax,
y: ay,
w: aw,
h: ah,
});
}
} else {
cap.on_motion(xrel, yrel); cap.on_motion(xrel, yrel);
} }
} }
}
}
Event::MouseButtonDown { mouse_btn, .. } => { Event::MouseButtonDown { mouse_btn, .. } => {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if !cap.captured() { if !cap.captured() {
// The engaging click is suppressed toward the host. // The engaging click is suppressed toward the host.
cap.engage(); cap.engage();
apply_capture(&mut window, &mouse, true, cap.desktop()); apply_capture(&mut window, &mouse, true);
} else { } else {
cap.on_button_down(mouse_btn); cap.on_button_down(mouse_btn);
} }
@@ -763,7 +709,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
while escape_rx.try_recv().is_ok() { while escape_rx.try_recv().is_ok() {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.release(true) { if cap.release(true) {
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
} }
} }
if fullscreen && !opts.fullscreen { if fullscreen && !opts.fullscreen {
@@ -776,7 +722,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(st) = &mut stream { if let Some(st) = &mut stream {
tracing::info!("controller chord: disconnect"); tracing::info!("controller chord: disconnect");
st.request_quit(); st.request_quit();
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
} }
} }
@@ -867,26 +813,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.ok(); .ok();
gamepad.attach(c.clone()); gamepad.attach(c.clone());
st.clock_offset = Some(c.clock_offset_shared()); st.clock_offset = Some(c.clock_offset_shared());
// gamescope's EIS grants only a relative pointer — absolute sends let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
// would be dropped, so the desktop model is pinned off there. Auto
// (an older host that didn't say) stays allowed: Windows hosts and
// pre-Welcome-compositor Linux hosts both take absolute.
let abs_ok = c.resolved_compositor != CompositorPref::Gamescope;
if opts.mouse_mode == MouseMode::Desktop && !abs_ok {
tracing::info!(
"desktop mouse mode unavailable on a gamescope host \
(relative-only input) using capture"
);
}
let mut cap = Capture::new(
c.clone(),
opts.touch_mode,
opts.invert_scroll,
opts.mouse_mode,
abs_ok,
);
cap.engage(); // capture engages when the stream starts (ui_stream parity) cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true, cap.desktop()); apply_capture(&mut window, &mouse, true);
st.capture = Some(cap); st.capture = Some(cap);
st.connector = Some(c); st.connector = Some(c);
if let Some(f) = opts.on_connected.as_mut() { if let Some(f) = opts.on_connected.as_mut() {
@@ -936,7 +865,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(st) = stream.take() { if let Some(st) = stream.take() {
st.shutdown(); st.shutdown();
} }
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
if let Some(o) = overlay.as_mut() { if let Some(o) = overlay.as_mut() {
// A user-canceled dial ends silently — no error scene. // A user-canceled dial ends silently — no error scene.
if canceled { if canceled {
@@ -953,7 +882,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = &mut st.capture { if let Some(cap) = &mut st.capture {
cap.release(true); cap.release(true);
} }
apply_capture(&mut window, &mouse, false, false); apply_capture(&mut window, &mouse, false);
match &mode { match &mode {
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
ModeCtl::Browse(_) => { ModeCtl::Browse(_) => {
@@ -1543,22 +1472,11 @@ impl ResizeIndicator {
/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process /// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process
/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor /// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor
/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts). /// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts).
/// fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool) {
/// The `desktop` mouse model never locks: the pointer roams (and leaves the window) mouse.set_relative_mouse_mode(window, on);
/// freely, the local cursor is hidden over the window — the host's composited cursor,
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
/// who draws it) — and system chords stay local (a remote desktop is something you
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
fn apply_capture(
window: &mut sdl3::video::Window,
mouse: &sdl3::mouse::MouseUtil,
on: bool,
desktop: bool,
) {
mouse.set_relative_mouse_mode(window, on && !desktop);
mouse.show_cursor(!on); mouse.show_cursor(!on);
#[cfg(windows)] #[cfg(windows)]
window.set_keyboard_grab(on && !desktop); window.set_keyboard_grab(on);
} }
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)? /// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
@@ -1676,7 +1594,7 @@ struct PresentedWindow {
/// The capture hints (`ui_stream` parity — the words the user reads while released). /// The capture hints (`ui_stream` parity — the words the user reads while released).
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"; Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave"; Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
+1 -9
View File
@@ -30,17 +30,9 @@ impl Presenter {
// switch modes before anything touches this frame. Only where the surface // switch modes before anything touches this frame. Only where the surface
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader // offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
// tonemaps (mode 1). // tonemaps (mode 1).
//
// CPU frames NEVER take the HDR10 surface: software decode uploads swscale RGBA with
// no CSC/tonemap pass, so on a mode-0 swapchain that sRGB-encoded content would be
// composed as PQ — the field-reported psychedelic cyan/magenta picture (reproduced
// 2026-07-21: Fedora-class client, no hw HEVC decode, GNOME/Mesa offering HDR10 even
// on an SDR desktop). On the SDR swapchain the same frames are merely untonemapped
// (washed out) — wrong in the known, benign way until the CPU lane grows a real
// PQ→sRGB pass.
let frame_pq = match &input { let frame_pq = match &input {
FrameInput::Redraw => None, FrameInput::Redraw => None,
FrameInput::Cpu(_) => Some(false), FrameInput::Cpu(f) => Some(f.color.is_pq()),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
FrameInput::Dmabuf(d) => Some(d.color.is_pq()), FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()), FrameInput::VkFrame(v) => Some(v.color.is_pq()),
-9
View File
@@ -617,15 +617,6 @@ pub(super) fn pick_formats(
surface: vk::SurfaceKHR, surface: vk::SurfaceKHR,
colorspace_ext: bool, colorspace_ext: bool,
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> { ) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
// `PUNKTFUNK_HDR10=0` (explicit-off grammar) refuses the HDR10/ST.2084 swapchain outright,
// pinning PQ streams to the shader tonemap on an SDR surface. Two reasons this exists:
// desktop compositors newly offer HDR10 even on SDR desktops (GNOME 48 / Plasma 6 with
// Mesa ≥ 25.1 — a lane that otherwise engages silently), and it is the A/B lever that
// splits "HDR10 passthrough composes wrong" from "the decoded planes are wrong" in the
// field without rebuilding anything.
let colorspace_ext = colorspace_ext
&& !std::env::var("PUNKTFUNK_HDR10")
.is_ok_and(|v| matches!(v.as_str(), "0" | "false" | "off" | "no"));
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
let mut sdr = None; let mut sdr = None;
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
-28
View File
@@ -254,34 +254,6 @@ pub fn detect() -> Result<Compositor> {
} }
} }
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
/// or take over box sessions — they may only attach to an already-live output, and fail fast
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
pub struct RebuildProbeScope(());
pub fn rebuild_probe_scope() -> RebuildProbeScope {
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
RebuildProbeScope(())
}
impl Drop for RebuildProbeScope {
fn drop(&mut self) {
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
/// takeover-restart) must be skipped while true.
pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
}
/// Open the virtual-display driver for `compositor`. /// Open the virtual-display driver for `compositor`.
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> { pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -325,29 +325,6 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
if steamos_session_present() { if steamos_session_present() {
return create_managed_session_steamos(mode); return create_managed_session_steamos(mode);
} }
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
// sessions — right after a capture loss the caller's session detection can be stale, and a
// destructive rebuild here would fight the session the user just switched to.
if crate::rebuild_probe_active() {
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
});
if same_mode {
if let Some(node_id) = find_gamescope_node() {
point_injector_at_eis();
tracing::info!(
node_id,
"gamescope session: attach-only probe reusing live node"
);
return Ok(managed_output(node_id, mode));
}
}
return Err(anyhow!(
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
to stop/relaunch box sessions (re-detection follows the live session)"
));
}
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the // Steam is single-instance: if the box autologged into gaming mode on a physical display (the
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
@@ -630,17 +607,12 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?; std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
} }
// UnsetEnvironment: the same headless-must-not-attach armor `launch_session` gives its
// transient unit — the manager env can carry a stale desktop DISPLAY/WAYLAND_DISPLAY (from a
// portal settle), and gamescope would abort trying to attach to it instead of becoming the
// display server. Unit-scoped belt-and-suspenders on top of the observe_session_instance scrub.
let body = format!( let body = format!(
"[Service]\n\ "[Service]\n\
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\ Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
Environment=PF_W={w}\n\ Environment=PF_W={w}\n\
Environment=PF_H={h}\n\ Environment=PF_H={h}\n\
Environment=PF_HZ={hz}\n\ Environment=PF_HZ={hz}\n",
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
shim = shim_dir.display(), shim = shim_dir.display(),
w = mode.width, w = mode.width,
h = mode.height, h = mode.height,
@@ -678,16 +650,6 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
} }
*guard = None; // tracked session lost its node — fall through to a clean restart *guard = None; // tracked session lost its node — fall through to a clean restart
} }
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
// this restart steal the seat back from the KDE session the user had just switched to.
if crate::rebuild_probe_active() {
return Err(anyhow!(
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
re-detection follows it)"
));
}
let shim_dir = write_headless_shim()?; let shim_dir = write_headless_shim()?;
write_steamos_dropin(&shim_dir, mode)?; write_steamos_dropin(&shim_dir, mode)?;
systemctl_user(&["daemon-reload"]); systemctl_user(&["daemon-reload"]);
@@ -57,13 +57,6 @@ pub fn observe_session_instance(active: &ActiveSession) {
if let Some(old) = compositor_for_kind(prev.0) { if let Some(old) = compositor_for_kind(prev.0) {
registry::invalidate_backend(old.id()); registry::invalidate_backend(old.id());
} }
// The dead desktop's socket vars may still sit in the systemd --user manager env
// ([`settle_desktop_portal`]'s import-environment) — scrub them NOW, or the next
// `gamescope-session.target` start inherits a stale WAYLAND_DISPLAY and gamescope
// runs NESTED against the dead desktop socket instead of becoming the display
// server ("Failed to connect to wayland socket: wayland-0" — kept a Deck's Game
// Mode from starting at all, observed live 2026-07-21).
scrub_desktop_manager_env();
} }
let epoch = bump_session_epoch(); let epoch = bump_session_epoch();
tracing::info!( tracing::info!(
@@ -77,23 +70,6 @@ pub fn observe_session_instance(active: &ActiveSession) {
*last = Some(cur); *last = Some(cur);
} }
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
/// persist in the manager otherwise, and every later user unit inherits them — including
/// `gamescope-session.target`, whose gamescope then aborts trying to attach to the dead desktop
/// socket. Best-effort; the D-Bus activation env has no unset op, but gamescope-session is
/// systemd-started, so the manager scrub is the one that matters. (A desktop restart re-imports
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
#[cfg(target_os = "linux")]
fn scrub_desktop_manager_env() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
.status();
}
#[cfg(not(target_os = "linux"))]
fn scrub_desktop_manager_env() {}
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die /// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are /// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]). /// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
+4 -20
View File
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds. /// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
@@ -64,27 +64,11 @@ impl Drop for Shared {
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket /// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so /// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
/// workers don't linger as zombies for more than one capture generation. /// workers don't linger as zombies for more than one capture generation.
static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new()); static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
/// How long past `REPLY_TIMEOUT` a parked worker may linger before it is force-killed. A worker
/// wedged INSIDE a driver call never observes socket EOF, so `try_wait` alone would keep it (and
/// its CUcontext + BufferPool — order hundreds of MB of VRAM) forever.
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
fn sweep_reaper() { fn sweep_reaper() {
let mut list = REAPER.lock().unwrap(); let mut list = REAPER.lock().unwrap();
let now = Instant::now(); list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
list.retain_mut(|(c, parked)| {
if matches!(c.try_wait(), Ok(Some(_))) {
return false; // exited on its own → reaped
}
if now.duration_since(*parked) > REAPER_KILL_DEADLINE {
let _ = c.kill();
let _ = c.wait();
return false; // wedged past the deadline → force-killed + reaped
}
true
});
} }
/// Fd pinned to this process's own executable image, opened (once, lazily) via the /// Fd pinned to this process's own executable image, opened (once, lazily) via the
@@ -471,7 +455,7 @@ impl Drop for RemoteImporter {
// gone; park the rest for the next sweep. // gone; park the rest for the next sweep.
if let Some(mut child) = self.child.take() { if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) { if !matches!(child.try_wait(), Ok(Some(_))) {
REAPER.lock().unwrap().push((child, Instant::now())); REAPER.lock().unwrap().push(child);
} }
} }
sweep_reaper(); sweep_reaper();
+28 -87
View File
@@ -251,32 +251,6 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuStreamSynchronize(stream), "cuStreamSynchronize") ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
} }
/// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers
/// only (the direct-NVENC submit path with `NvEncSetIOCudaStreams` bound to this stream): the
/// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream
/// stream work (the encode) has finished.
unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
}
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
if sync {
copy_blocking(copy, what)
} else {
copy_async(copy, what)
}
}
/// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering
/// (the direct-NVENC `NvEncSetIOCudaStreams` hookup). Null = the NULL stream (priority-stream
/// creation failed) — callers should treat null as "stream-ordering unavailable" and keep their
/// blocking copies. The shared context must be current on this thread.
pub fn copy_stream_handle() -> *mut c_void {
copy_stream() // CUstream IS *mut c_void (opaque CUstream_st*)
}
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path. /// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
pub const CURSOR_MAX: u32 = 256; pub const CURSOR_MAX: u32 = 256;
@@ -380,7 +354,6 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_surf, mut a_cur) = (surf, self.cur_buf); let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32); let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
@@ -397,7 +370,7 @@ impl CursorBlend {
&mut a_ox as *mut _ as *mut c_void, &mut a_ox as *mut _ as *mut c_void,
&mut a_oy as *mut _ as *mut c_void, &mut a_oy as *mut _ as *mut c_void,
]; ];
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args, sync) self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args)
} }
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`. /// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
@@ -412,7 +385,6 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_base, mut a_cur) = (base, self.cur_buf); let (mut a_base, mut a_cur) = (base, self.cur_buf);
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32); let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
@@ -429,7 +401,7 @@ impl CursorBlend {
&mut a_ox as *mut _ as *mut c_void, &mut a_ox as *mut _ as *mut c_void,
&mut a_oy as *mut _ as *mut c_void, &mut a_oy as *mut _ as *mut c_void,
]; ];
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args, sync) self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args)
} }
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`). /// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
@@ -444,7 +416,6 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf); let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32); let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
@@ -470,20 +441,16 @@ impl CursorBlend {
(a_cw as u32).div_ceil(2), (a_cw as u32).div_ceil(2),
(a_ch as u32).div_ceil(2), (a_ch as u32).div_ceil(2),
&mut args, &mut args,
sync,
) )
} }
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream; `sync` waits /// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream, then synchronize.
/// for it, `!sync` leaves completion to the stream (stream-ordered consumers only — the
/// kernel PARAMETERS are copied at launch time, so the arg locals need not outlive the call).
fn launch( fn launch(
&self, &self,
f: CUfunction, f: CUfunction,
work_w: u32, work_w: u32,
work_h: u32, work_h: u32,
args: &mut [*mut c_void], args: &mut [*mut c_void],
sync: bool,
) -> Result<()> { ) -> Result<()> {
if work_w == 0 || work_h == 0 { if work_w == 0 || work_h == 0 {
return Ok(()); return Ok(());
@@ -491,11 +458,9 @@ impl CursorBlend {
const B: u32 = 16; const B: u32 = 16;
let stream = copy_stream(); let stream = copy_stream();
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live // SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
// locals whose types match the kernel's C parameters (per the call site above) — CUDA // locals whose types match the kernel's C parameters (per the call site above); grid/block
// copies the parameter values during `cuLaunchKernel` itself, so they need not outlive // dims are non-zero. Launched on the copy stream (ordered after the input-surface copy that
// the call. Grid/block dims are non-zero. Launched on the copy stream (ordered after the // `copy_into_slot` already synchronized) then synchronized. Requires the context current.
// input-surface copy issued on the same stream); `sync` waits, `!sync` leaves ordering to
// the stream (the NVENC IO-stream binding). Requires the context current.
unsafe { unsafe {
ck( ck(
cuLaunchKernel( cuLaunchKernel(
@@ -513,10 +478,7 @@ impl CursorBlend {
), ),
"cuLaunchKernel(cursor)", "cuLaunchKernel(cursor)",
)?; )?;
if sync { ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")?;
}
Ok(())
} }
} }
} }
@@ -1041,13 +1003,7 @@ impl RegisteredTexture {
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl` // SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
// (its only constructor), so the wrappers forward to the live table; the caller holds the // (its only constructor), so the wrappers forward to the live table; the caller holds the
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps // GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
// `count == 1` resource via `&mut self.resource` (a live field). It is issued on // `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
// `copy_stream()` — NOT the NULL stream — because map's only ordering guarantee is that
// prior GL work completes before subsequent CUDA work issued IN THE STREAM PASSED TO IT;
// the copy below runs on `copy_stream()` (a `CU_STREAM_NON_BLOCKING` stream, exempt from
// implicit NULL-stream ordering), so mapping on NULL left the copy free to race the GL
// de-tile/CSC that produced this texture (glFlush only, no fence) — intermittent torn or
// stale frames under GPU load. Map, copy, and unmap now all share `copy_stream()`.
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local // `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live // `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid // local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
@@ -1056,12 +1012,12 @@ impl RegisteredTexture {
// we always unmap afterward (even on error), keeping the map/unmap pair balanced. // we always unmap afterward (even on error), keeping the map/unmap pair balanced.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources", "cuGraphicsMapResources",
)?; )?;
let mut array: CUarray = std::ptr::null_mut(); let mut array: CUarray = std::ptr::null_mut();
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 { if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
bail!("cuGraphicsSubResourceGetMappedArray failed"); bail!("cuGraphicsSubResourceGetMappedArray failed");
} }
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
@@ -1075,7 +1031,7 @@ impl RegisteredTexture {
..Default::default() ..Default::default()
}; };
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2"); let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res res
} }
} }
@@ -1102,12 +1058,12 @@ impl RegisteredTexture {
// so the map/unmap pair stays balanced and the array outlives the copy. // so the map/unmap pair stays balanced and the array outlives the copy.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources", "cuGraphicsMapResources",
)?; )?;
let mut array: CUarray = std::ptr::null_mut(); let mut array: CUarray = std::ptr::null_mut();
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 { if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
bail!("cuGraphicsSubResourceGetMappedArray failed"); bail!("cuGraphicsSubResourceGetMappedArray failed");
} }
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
@@ -1121,7 +1077,7 @@ impl RegisteredTexture {
..Default::default() ..Default::default()
}; };
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2(plane)"); let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2(plane)");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res res
} }
} }
@@ -1166,13 +1122,10 @@ pub fn copy_mapped_yuv444(
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported /// Copy a pitched device buffer into another device region (device→device), e.g. our imported
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels. /// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
/// The caller must have the shared context current on this thread (see [`make_current`]). /// The caller must have the shared context current on this thread (see [`make_current`]).
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_device_to_device( pub fn copy_device_to_device(
src: &DeviceBuffer, src: &DeviceBuffer,
dst_ptr: CUdeviceptr, dst_ptr: CUdeviceptr,
dst_pitch: usize, dst_pitch: usize,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
srcMemoryType: CU_MEMORYTYPE_DEVICE, srcMemoryType: CU_MEMORYTYPE_DEVICE,
@@ -1185,27 +1138,23 @@ pub fn copy_device_to_device(
Height: src.height as usize, Height: src.height as usize,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_issue` is unsafe (issues a CUDA copy); the caller must have the shared // SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving // context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the enqueue: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/`dstPitch` the // the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
// caller's live region, `width*4`×`height` within both; `sync: false` shifts the source- // `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
// lifetime obligation to the caller (documented above). Wrapper → live table. unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") }
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(dev->dev)", sync) }
} }
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface /// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` + /// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is /// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current. /// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_nv12_to_device( pub fn copy_nv12_to_device(
src: &DeviceBuffer, src: &DeviceBuffer,
y_dst: CUdeviceptr, y_dst: CUdeviceptr,
y_pitch: usize, y_pitch: usize,
uv_dst: CUdeviceptr, uv_dst: CUdeviceptr,
uv_pitch: usize, uv_pitch: usize,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (src_uv_ptr, src_uv_pitch) = src let (src_uv_ptr, src_uv_pitch) = src
.uv .uv
@@ -1234,16 +1183,15 @@ pub fn copy_nv12_to_device(
Height: h / 2, Height: h / 2,
..Default::default() ..Default::default()
}; };
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared // SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each // context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live // synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are // NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy // the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation // `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
// to the caller (documented above). Wrappers → live table.
unsafe { unsafe {
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?; copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync) copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
} }
} }
@@ -1251,13 +1199,7 @@ pub fn copy_nv12_to_device(
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context). /// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the /// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
/// single allocation. The caller must have the shared context current. /// single allocation. The caller must have the shared context current.
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]) -> Result<()> {
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_yuv444_to_device(
src: &DeviceBuffer,
dsts: [(CUdeviceptr, usize); 3],
sync: bool,
) -> Result<()> {
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer"); anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
let w = src.width as usize; let w = src.width as usize;
let h = src.height as usize; let h = src.height as usize;
@@ -1273,13 +1215,12 @@ pub fn copy_yuv444_to_device(
Height: h, Height: h,
..Default::default() ..Default::default()
}; };
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared // SAFETY: unsafe `copy_blocking` device→device copy; the caller must have the shared
// context current (documented). `&copy` is a live local outliving the enqueue; // context current (documented). `&copy` is a live local outliving the synchronous call;
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444` // `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits // checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented // both. Wrapper → live table.
// above). Wrapper → live table. unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
} }
Ok(()) Ok(())
} }
-9
View File
@@ -691,15 +691,6 @@ impl EglImporter {
width: u32, width: u32,
height: u32, height: u32,
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
// Even dimensions only: the UV copy walks `height.div_ceil(2)` chroma rows (the correct NV12
// count), but the pooled UV plane is sized at `height/2` rows — for an odd height those
// disagree by one row and the copy writes a full `uv_pitch` past the allocation (OOB device
// write / CUDA_ERROR_ILLEGAL_ADDRESS that poisons the shared context). Reject here, matching
// the guards `Nv12Blit::new`/`Yuv444Blit::new` already carry.
anyhow::ensure!(
width % 2 == 0 && height % 2 == 0,
"LINEAR NV12 needs even dimensions (got {width}x{height})"
);
cuda::make_current()?; cuda::make_current()?;
if self if self
.linear_nv12_pool .linear_nv12_pool
+55 -196
View File
@@ -86,11 +86,9 @@ impl VkBridge {
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically // SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system // verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`, // libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
// `exts`, `prio`, `qci`, `gp_info`, and the inline infos) that all live for the duration of // `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
// the synchronous `create_*`/`enumerate_*` call that reads them — the ladder loop rebuilds // synchronous `create_*`/`enumerate_*` call that reads them — in particular the
// `prio`/`gp_info`/`qci`/`exts` fresh per attempt, so every `enabled_extension_names(&exts)` // `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
// / `queue_priorities(&prio)` / `push_next(&mut gp_info)` borrow outlives its own
// `create_device` call.
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and // Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This // checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
// constructor shares nothing across threads. // constructor shares nothing across threads.
@@ -124,93 +122,23 @@ impl VkBridge {
.ok_or_else(|| anyhow!("no compute-capable queue family"))? .ok_or_else(|| anyhow!("no compute-capable queue family"))?
as u32; as u32;
// Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the let exts = [
// VkBridge): the LINEAR/gamescope CSC dispatch shares the SM/compute cores with the
// game, so ask for an elevated global priority to get scheduled ahead of it.
// `PUNKTFUNK_VK_QUEUE_PRIORITY` = off | high | realtime (default realtime); the
// create loop downgrades REALTIME→HIGH→none on NOT_PERMITTED (and retries a plain
// create on INITIALIZATION_FAILED) so a refused class never fails the bridge.
let gp_ext = std::env::var("PUNKTFUNK_VK_QUEUE_PRIORITY")
.ok()
.as_deref()
.map_or(Some(vk::QueueGlobalPriorityKHR::REALTIME), |v| match v {
"off" | "0" => None,
"high" => Some(vk::QueueGlobalPriorityKHR::HIGH),
_ => Some(vk::QueueGlobalPriorityKHR::REALTIME),
})
.and_then(|want| {
// Enable whichever alias the driver advertises (KHR = the promoted name).
let props = instance.enumerate_device_extension_properties(phys).ok()?;
let has = |name: &std::ffi::CStr| {
props
.iter()
.any(|p| p.extension_name_as_c_str() == Ok(name))
};
if has(vk::KHR_GLOBAL_PRIORITY_NAME) {
Some((vk::KHR_GLOBAL_PRIORITY_NAME, want))
} else if has(vk::EXT_GLOBAL_PRIORITY_NAME) {
Some((vk::EXT_GLOBAL_PRIORITY_NAME, want))
} else {
None
}
});
let base_exts = [
ash::khr::external_memory_fd::NAME.as_ptr(), ash::khr::external_memory_fd::NAME.as_ptr(),
ash::ext::external_memory_dma_buf::NAME.as_ptr(), ash::ext::external_memory_dma_buf::NAME.as_ptr(),
]; ];
let mut try_priority = gp_ext.map(|(_, want)| want);
let device = loop {
let prio = [1.0f32]; let prio = [1.0f32];
let mut gp_info = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default() let qci = [vk::DeviceQueueCreateInfo::default()
.global_priority(try_priority.unwrap_or(vk::QueueGlobalPriorityKHR::MEDIUM));
let mut qci0 = vk::DeviceQueueCreateInfo::default()
.queue_family_index(qf) .queue_family_index(qf)
.queue_priorities(&prio); .queue_priorities(&prio)];
let mut exts: Vec<*const std::ffi::c_char> = base_exts.to_vec(); let device = instance
if try_priority.is_some() { .create_device(
qci0 = qci0.push_next(&mut gp_info);
exts.push(gp_ext.expect("try_priority implies gp_ext").0.as_ptr());
}
let qci = [qci0];
match instance.create_device(
phys, phys,
&vk::DeviceCreateInfo::default() &vk::DeviceCreateInfo::default()
.queue_create_infos(&qci) .queue_create_infos(&qci)
.enabled_extension_names(&exts), .enabled_extension_names(&exts),
None, None,
) { )
Ok(d) => { .context("vkCreateDevice (external-memory extensions supported?)")?;
if let Some(p) = try_priority {
tracing::info!(
priority = ?p,
"VkBridge queue at elevated global priority (CSC schedules \
ahead of a GPU-bound game where the driver honors it)"
);
}
break d;
}
// A refused class must never fail the bridge — walk the ladder down.
Err(
vk::Result::ERROR_NOT_PERMITTED_KHR
| vk::Result::ERROR_INITIALIZATION_FAILED,
) if try_priority == Some(vk::QueueGlobalPriorityKHR::REALTIME) => {
try_priority = Some(vk::QueueGlobalPriorityKHR::HIGH);
}
Err(
vk::Result::ERROR_NOT_PERMITTED_KHR
| vk::Result::ERROR_INITIALIZATION_FAILED,
) if try_priority.is_some() => {
tracing::debug!(
"global-priority queue not permitted — VkBridge at default priority"
);
try_priority = None;
}
Err(e) => {
return Err(e)
.context("vkCreateDevice (external-memory extensions supported?)")
}
}
};
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device); let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
let queue = device.get_device_queue(qf, 0); let queue = device.get_device_queue(qf, 0);
@@ -265,17 +193,10 @@ impl VkBridge {
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`. /// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> { unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
let dup = libc::dup(fd); let dup = libc::dup(fd);
if dup < 0 { if dup < 0 {
bail!("dup(dmabuf fd)"); bail!("dup(dmabuf fd)");
} }
// Own the dup so every early return BEFORE Vulkan consumes it (at `allocate_memory` success)
// closes it. `SrcBuf` holds raw handles with no Drop and is only populated on the success
// path, so each fallible step below must also destroy the buffer it created — otherwise a
// failed import (which the worker survives and the caller retries every frame) leaks a
// VkBuffer + VkDeviceMemory + fd per frame for the worker's whole lifetime.
let dup = OwnedFd::from_raw_fd(dup);
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default() let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let buffer = self let buffer = self
@@ -291,55 +212,41 @@ impl VkBridge {
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
) )
.context("create import buffer")?; // `dup` drops → closes on failure .context("create import buffer")?;
let mut fd_props = vk::MemoryFdPropertiesKHR::default(); let mut fd_props = vk::MemoryFdPropertiesKHR::default();
if let Err(e) = self.ext_fd.get_memory_fd_properties( self.ext_fd
.get_memory_fd_properties(
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup.as_raw_fd(), dup,
&mut fd_props, &mut fd_props,
) { )
self.device.destroy_buffer(buffer, None); .context("vkGetMemoryFdPropertiesKHR")?;
return Err(e).context("vkGetMemoryFdPropertiesKHR");
}
let reqs = self.device.get_buffer_memory_requirements(buffer); let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type = match self.memory_type( let mem_type = self.memory_type(
reqs.memory_type_bits & fd_props.memory_type_bits, reqs.memory_type_bits & fd_props.memory_type_bits,
vk::MemoryPropertyFlags::empty(), vk::MemoryPropertyFlags::empty(),
) { )?;
Ok(t) => t,
Err(e) => {
self.device.destroy_buffer(buffer, None);
return Err(e);
}
};
// Vulkan takes ownership of the fd on a SUCCESSFUL import: hand over the raw fd now, and on
// failure close it ourselves (matching the original contract) plus destroy the buffer.
let raw = dup.into_raw_fd();
let mut import = vk::ImportMemoryFdInfoKHR::default() let mut import = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT) .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(raw); .fd(dup); // Vulkan takes ownership of `dup` on success
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer); let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = match self.device.allocate_memory( let memory = self
.device
.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size.max(size)) .allocation_size(reqs.size.max(size))
.memory_type_index(mem_type) .memory_type_index(mem_type)
.push_next(&mut import) .push_next(&mut import)
.push_next(&mut dedicated), .push_next(&mut dedicated),
None, None,
) { )
Ok(m) => m, .map_err(|e| {
Err(e) => { libc::close(dup); // failed import does not consume the fd
libc::close(raw); // failed import does not consume the fd anyhow!("import dmabuf memory: {e}")
self.device.destroy_buffer(buffer, None); })?;
return Err(anyhow!("import dmabuf memory: {e}")); self.device
} .bind_buffer_memory(buffer, memory, 0)
}; .context("bind import memory")?;
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
// `memory` owns the imported fd — freeing it releases the fd too.
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("bind import memory");
}
self.src_cache.insert( self.src_cache.insert(
fd, fd,
SrcBuf { SrcBuf {
@@ -356,11 +263,11 @@ impl VkBridge {
if self.dst.as_ref().is_some_and(|d| d.size >= size) { if self.dst.as_ref().is_some_and(|d| d.size >= size) {
return Ok(()); return Ok(());
} }
// Build the replacement FULLY before retiring the old one. Previously the old dst was if let Some(old) = self.dst.take() {
// destroyed and `self.dst` nulled up front, so a failed rebuild both dropped the working self.device.destroy_buffer(old.buffer, None);
// buffer AND leaked every object the partial rebuild created (`buffer`/`memory` are raw ash self.device.free_memory(old.memory, None);
// handles with no Drop, and `VkBridge::drop` only frees the live `self.dst`). Now every // old.cuda drops its mapping with it
// fallible step unwinds locally, and the swap happens only on full success. }
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default() let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let buffer = self let buffer = self
@@ -378,63 +285,35 @@ impl VkBridge {
.context("create export buffer")?; .context("create export buffer")?;
let reqs = self.device.get_buffer_memory_requirements(buffer); let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type = let mem_type =
match self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL) { self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
Ok(t) => t,
Err(e) => {
self.device.destroy_buffer(buffer, None);
return Err(e);
}
};
let mut export = vk::ExportMemoryAllocateInfo::default() let mut export = vk::ExportMemoryAllocateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer); let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = match self.device.allocate_memory( let memory = self
.device
.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size) .allocation_size(reqs.size)
.memory_type_index(mem_type) .memory_type_index(mem_type)
.push_next(&mut export) .push_next(&mut export)
.push_next(&mut dedicated), .push_next(&mut dedicated),
None, None,
) { )
Ok(m) => m, .context("allocate exportable memory")?;
Err(e) => { self.device
self.device.destroy_buffer(buffer, None); .bind_buffer_memory(buffer, memory, 0)
return Err(e).context("allocate exportable memory"); .context("bind export memory")?;
} let opaque_fd = self
}; .ext_fd
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) { .get_memory_fd(
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("bind export memory");
}
let opaque_fd = match self.ext_fd.get_memory_fd(
&vk::MemoryGetFdInfoKHR::default() &vk::MemoryGetFdInfoKHR::default()
.memory(memory) .memory(memory)
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD), .handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
) { )
Ok(f) => f, .context("vkGetMemoryFdKHR")?;
Err(e) => {
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("vkGetMemoryFdKHR");
}
};
// CUDA imports (and on success owns) the exported fd. Size must match the allocation. // CUDA imports (and on success owns) the exported fd. Size must match the allocation.
// `import_owned_fd` closes `opaque_fd` on its own failure, so only the Vulkan objects unwind. let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
let cuda = match cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size) { .context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
Ok(c) => c,
Err(e) => {
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("cuImportExternalMemory(OPAQUE_FD from Vulkan)");
}
};
// Full success: retire the previous buffer now, then publish the new one.
if let Some(old) = self.dst.take() {
self.device.destroy_buffer(old.buffer, None);
self.device.free_memory(old.memory, None);
// old.cuda drops its mapping with it
}
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready"); tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
self.dst = Some(DstBuf { self.dst = Some(DstBuf {
buffer, buffer,
@@ -665,19 +544,9 @@ impl VkBridge {
self.device self.device
.queue_submit(self.queue, &[submit], self.fence) .queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?; .context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still self.device
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
// work references it). Drain the GPU and reset the fence before propagating so the shared
// cmd/fence return clean.
if let Err(e) = self
.device
.wait_for_fences(&[self.fence], true, 1_000_000_000) .wait_for_fences(&[self.fence], true, 1_000_000_000)
{ .context("fence wait")?;
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
self.device self.device
.reset_fences(&[self.fence]) .reset_fences(&[self.fence])
.context("reset fence")?; .context("reset fence")?;
@@ -770,19 +639,9 @@ impl VkBridge {
self.device self.device
.queue_submit(self.queue, &[submit], self.fence) .queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?; .context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still self.device
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
// work references it). Drain the GPU and reset the fence before propagating so the shared
// cmd/fence return clean.
if let Err(e) = self
.device
.wait_for_fences(&[self.fence], true, 1_000_000_000) .wait_for_fences(&[self.fence], true, 1_000_000_000)
{ .context("fence wait")?;
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
self.device self.device
.reset_fences(&[self.fence]) .reset_fences(&[self.fence])
.context("reset fence")?; .context("reset fence")?;
-5
View File
@@ -33,11 +33,6 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause). # NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
fec-rs = { path = "vendor/fec-rs" } fec-rs = { path = "vendor/fec-rs" }
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES
# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there).
# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
chacha20poly1305 = "0.10"
zerocopy = { version = "0.8", features = ["derive"] } zerocopy = { version = "0.8", features = ["derive"] }
bytes = "1" bytes = "1"
socket2 = { version = "0.6", features = [ socket2 = { version = "0.6", features = [
+12 -22
View File
@@ -1,8 +1,7 @@
//! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI. //! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI.
//! //!
//! Two layers: //! Two layers:
//! - `crypto/*` — the isolated AEAD primitives (AES-128-GCM + the negotiated //! - `crypto/*` — the isolated AES-128-GCM primitives on one ~MTU shard.
//! ChaCha20-Poly1305) on one ~MTU shard.
//! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process //! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process
//! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble → //! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble →
//! FEC decode → open. This is what a throughput/latency regression in the core would show up in. //! FEC decode → open. This is what a throughput/latency regression in the core would show up in.
@@ -12,11 +11,11 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::{SessionCrypto, SessionKey}; use punktfunk_core::crypto::SessionCrypto;
use punktfunk_core::session::Session; use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair; use punktfunk_core::transport::loopback_pair;
const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size) const TAG_LEN: usize = 16; // AES-GCM authentication tag
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
fn cfg(role: Role, scheme: FecScheme) -> Config { fn cfg(role: Role, scheme: FecScheme) -> Config {
@@ -39,29 +38,21 @@ fn cfg(role: Role, scheme: FecScheme) -> Config {
shard_payload: SHARD, shard_payload: SHARD,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt: true, // bench the real path — crypto is always on for punktfunk/1 encrypt: true, // bench the real path — crypto is always on for punktfunk/1
key: SessionKey::Aes128Gcm([7u8; 16]), key: [7u8; 16],
salt: [1, 2, 3, 4], salt: [1, 2, 3, 4],
loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery) loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery)
} }
} }
fn bench_crypto(c: &mut Criterion) { fn bench_crypto(c: &mut Criterion) {
let mut g = c.benchmark_group("crypto"); let host = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Host);
g.throughput(Throughput::Bytes(SHARD as u64)); let client = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Client);
// Both negotiated session AEADs. On the x86 / Apple Silicon this runs on, both must be
// line-rate-trivial — the chacha20 series is the host-side sealing-cost check for the
// negotiated soft-AES-armv7 path (design/chacha20-session-cipher.md §7). The AES series
// keeps its unsuffixed names so the CI regression compare retains its history.
for (suffix, key) in [
("", SessionKey::Aes128Gcm([7u8; 16])),
("_chacha20", SessionKey::ChaCha20Poly1305([7u8; 32])),
] {
let host = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Host);
let client = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Client);
let payload = vec![0xABu8; SHARD]; let payload = vec![0xABu8; SHARD];
let sealed = host.seal(0, &payload).unwrap(); let sealed = host.seal(0, &payload).unwrap();
g.bench_function(format!("seal{suffix}"), |b| { let mut g = c.benchmark_group("crypto");
g.throughput(Throughput::Bytes(SHARD as u64));
g.bench_function("seal", |b| {
let mut seq = 0u64; let mut seq = 0u64;
b.iter(|| { b.iter(|| {
let ct = host.seal(seq, black_box(&payload)).unwrap(); let ct = host.seal(seq, black_box(&payload)).unwrap();
@@ -69,7 +60,7 @@ fn bench_crypto(c: &mut Criterion) {
black_box(ct) black_box(ct)
}) })
}); });
g.bench_function(format!("seal_in_place{suffix}"), |b| { g.bench_function("seal_in_place", |b| {
let mut seq = 0u64; let mut seq = 0u64;
let mut buf = vec![0xABu8; SHARD + TAG_LEN]; let mut buf = vec![0xABu8; SHARD + TAG_LEN];
b.iter(|| { b.iter(|| {
@@ -77,10 +68,10 @@ fn bench_crypto(c: &mut Criterion) {
seq += 1; seq += 1;
}) })
}); });
g.bench_function(format!("open{suffix}"), |b| { g.bench_function("open", |b| {
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
}); });
g.bench_function(format!("open_in_place{suffix}"), |b| { g.bench_function("open_in_place", |b| {
// In-place open consumes the buffer, so each iteration restores the ciphertext first — // In-place open consumes the buffer, so each iteration restores the ciphertext first —
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot. // one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
let mut buf = sealed.clone(); let mut buf = sealed.clone();
@@ -89,7 +80,6 @@ fn bench_crypto(c: &mut Criterion) {
black_box(client.open_in_place(0, &mut buf).unwrap()); black_box(client.open_in_place(0, &mut buf).unwrap());
}) })
}); });
}
g.finish(); g.finish();
} }
+1
View File
@@ -10,6 +10,7 @@ fn main() {
println!("cargo:rerun-if-changed=src/abi.rs"); println!("cargo:rerun-if-changed=src/abi.rs");
println!("cargo:rerun-if-changed=src/config.rs"); println!("cargo:rerun-if-changed=src/config.rs");
println!("cargo:rerun-if-changed=src/input.rs"); println!("cargo:rerun-if-changed=src/input.rs");
println!("cargo:rerun-if-changed=src/client.rs");
println!("cargo:rerun-if-changed=src/error.rs"); println!("cargo:rerun-if-changed=src/error.rs");
println!("cargo:rerun-if-changed=cbindgen.toml"); println!("cargo:rerun-if-changed=cbindgen.toml");
+17 -97
View File
@@ -11,7 +11,6 @@
//! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`. //! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`.
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use crate::crypto::SessionKey;
use crate::error::PunktfunkStatus; use crate::error::PunktfunkStatus;
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::reanchor::{GateVerdict, ReanchorGate}; use crate::reanchor::{GateVerdict, ReanchorGate};
@@ -79,11 +78,6 @@ impl PunktfunkConfig {
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?; u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
let max_data_per_block = let max_data_per_block =
u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?; u16::try_from(self.max_data_per_block).map_err(|_| PunktfunkStatus::InvalidArg)?;
// The one narrowing here that differs by target width: on 32-bit (armeabi-v7a) an
// `as usize` silently truncates a >4 GiB value to a plausible-looking residue that
// passes validate() — reject it instead, like every narrowing above.
let max_frame_bytes =
usize::try_from(self.max_frame_bytes).map_err(|_| PunktfunkStatus::InvalidArg)?;
let cfg = Config { let cfg = Config {
role, role,
phase, phase,
@@ -93,12 +87,9 @@ impl PunktfunkConfig {
max_data_per_block, max_data_per_block,
}, },
shard_payload: self.shard_payload as usize, shard_payload: self.shard_payload as usize,
max_frame_bytes, max_frame_bytes: self.max_frame_bytes as usize,
encrypt: self.encrypt != 0, encrypt: self.encrypt != 0,
// The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no key: self.key,
// ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/
// Kotlin clients are aarch64 with AES CE and never want it.
key: SessionKey::Aes128Gcm(self.key),
salt: self.salt, salt: self.salt,
loopback_drop_period: self.loopback_drop_period, loopback_drop_period: self.loopback_drop_period,
}; };
@@ -134,12 +125,6 @@ pub struct PunktfunkFrame {
pub frame_index: u32, pub frame_index: u32,
pub pts_ns: u64, pub pts_ns: u64,
pub flags: u32, pub flags: u32,
/// Wall-clock reassembly-completion instant (ns since the Unix epoch, CLOCK_REALTIME — the
/// clock `pts_ns` and the skew handshake use). THIS is the receipt stamp for latency math:
/// a stamp the embedder takes itself at the poll return additionally contains the
/// pre-decode hand-off queue wait, so a client-side standing backlog would masquerade as
/// network latency (ABI v9 — the 2026-07 two-pair standing-latency investigation).
pub received_ns: u64,
} }
/// Snapshot of session counters. /// Snapshot of session counters.
@@ -406,7 +391,6 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
frame_index: f.frame_index, frame_index: f.frame_index,
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.flags, flags: f.flags,
received_ns: f.received_ns,
}; };
} }
PunktfunkStatus::Ok PunktfunkStatus::Ok
@@ -472,32 +456,24 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 { pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| { let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
let mut count = 0i32;
loop {
// Narrow scope: re-derive the handle and pull ONE event, then drop the borrow
// before dispatching. The callback may legally re-enter `punktfunk_*` on this
// handle (get_stats, send_input, clearing the callback) — with a `&mut` held
// across the call that re-entry aliased it (UB under noalias). Re-reading
// `input_cb` per iteration also makes a mid-drain
// `punktfunk_set_input_callback(s, NULL, NULL)` take effect immediately instead
// of firing the cleared callback for the queued remainder. (Freeing the session
// from inside the callback remains forbidden, as on every entry point.)
let (ev, cb) = {
let s = match unsafe { s.as_mut() } { let s = match unsafe { s.as_mut() } {
Some(s) => s, Some(s) => s,
None => return PunktfunkStatus::NullPointer as i32, None => return PunktfunkStatus::NullPointer as i32,
}; };
let cb = s.input_cb;
let mut count = 0i32;
loop {
match s.inner.poll_input() { match s.inner.poll_input() {
Ok(Some(ev)) => (ev, s.input_cb), Ok(Some(ev)) => {
Ok(None) => break,
Err(e) => return e.status() as i32,
}
};
if let Some((cb, user)) = cb { if let Some((cb, user)) = cb {
cb(&ev as *const InputEvent, user); cb(&ev as *const InputEvent, user);
} }
count += 1; count += 1;
} }
Ok(None) => break,
Err(e) => return e.status() as i32,
}
}
count count
})); }));
r.unwrap_or(PunktfunkStatus::Panic as i32) r.unwrap_or(PunktfunkStatus::Panic as i32)
@@ -902,8 +878,8 @@ pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
/// uinput X-Box 360 pad (the universal default — every game speaks XInput). /// uinput X-Box 360 pad (the universal default — every game speaks XInput).
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1; pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion — /// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on /// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
/// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360. /// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2; pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so /// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain; /// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
@@ -912,8 +888,8 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3; pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the /// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like /// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows /// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
/// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360. /// hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4; pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360. /// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
@@ -923,12 +899,10 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360. /// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a /// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
/// client's back paddles land on native slots. Honored on Linux (UHID `hid-playstation`) and /// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
/// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7; pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs + /// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
/// positional layout, gyro/accel, HD rumble. Honored only where available (Linux hosts, UHID /// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
/// `hid-nintendo`); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8; pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the /// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's /// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
@@ -1770,7 +1744,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
frame_index: f.frame_index, frame_index: f.frame_index,
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.flags, flags: f.flags,
received_ns: f.received_ns,
}; };
} }
PunktfunkStatus::Ok PunktfunkStatus::Ok
@@ -1933,13 +1906,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
} }
let AudioPcmState { decoder, pcm } = &mut *state; let AudioPcmState { decoder, pcm } = &mut *state;
let dec = decoder.as_mut().unwrap(); let dec = decoder.as_mut().unwrap();
// A header-only datagram (DTX silence — a legal wire form) must be SKIPPED, not
// decoded: `decode_float` treats an empty payload as a loss and synthesizes a full
// 120 ms of concealment for a ~5 ms slot, growing the playout ring without bound.
// Mirrors the host mic pump's guard; the sink underruns to silence on its own.
if pkt.data.is_empty() {
return PunktfunkStatus::NoFrame;
}
// `decode_float` divides the output buffer length by the channel count to get the // `decode_float` divides the output buffer length by the channel count to get the
// per-channel capacity; an empty payload requests packet-loss concealment. // per-channel capacity; an empty payload requests packet-loss concealment.
match dec.decode_float(&pkt.data, pcm, false) { match dec.decode_float(&pkt.data, pcm, false) {
@@ -2990,14 +2956,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
unsafe { *out = out_ev }; unsafe { *out = out_ev };
PunktfunkStatus::Ok PunktfunkStatus::Ok
} }
Err(e) => { Err(e) => e.status(),
// Release the parked payload once the embedder polls past it: clipboard
// traffic is sporadic, so without this a one-off 50 MiB paste stays resident
// for the rest of the session (there is no other release entry point). The
// borrow contract already says `out` data is valid only until the next call.
*c.last_clip.lock().unwrap() = None;
e.status()
}
} }
}) })
} }
@@ -3087,35 +3046,6 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
}) })
} }
/// The **live** host↔client wall-clock offset (nanoseconds, host minus client): the
/// connect-time estimate of [`punktfunk_connection_clock_offset_ns`], updated by every applied
/// mid-stream clock re-sync. Ongoing latency math (per-frame `received pts` splits, the
/// glass-to-glass meter) must use this one — after a wall-clock step/slew the frozen
/// connect-time value reads tens of milliseconds wrong for the rest of the session, while the
/// core itself has already re-synced. Same clock contract as the connect-time getter.
///
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
c: *const PunktfunkConnection,
offset_ns: *mut i64,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
unsafe {
if !offset_ns.is_null() {
*offset_ns = c.inner.clock_offset_now_ns();
}
}
PunktfunkStatus::Ok
})
}
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without /// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the /// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
/// stream continues at the new mode — the first new-mode access unit is an IDR with /// stream continues at the new mode — the first new-mode access unit is an IDR with
@@ -3255,11 +3185,6 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
out: *mut u64, out: *mut u64,
) -> PunktfunkStatus { ) -> PunktfunkStatus {
guard(|| { guard(|| {
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
// check, so an embedder that skips the status never reads an uninitialized slot.
if !out.is_null() {
unsafe { *out = 0 };
}
let c = match unsafe { c.as_ref() } { let c = match unsafe { c.as_ref() } {
Some(c) => c, Some(c) => c,
None => return PunktfunkStatus::NullPointer, None => return PunktfunkStatus::NullPointer,
@@ -3317,11 +3242,6 @@ pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
out: *mut bool, out: *mut bool,
) -> PunktfunkStatus { ) -> PunktfunkStatus {
guard(|| { guard(|| {
// The header promises "writes 0 on a NULL connection" — honor it BEFORE the handle
// check: an uninitialized byte is not even a valid C++/Swift bool to read.
if !out.is_null() {
unsafe { *out = false };
}
let c = match unsafe { c.as_ref() } { let c = match unsafe { c.as_ref() } {
Some(c) => c, Some(c) => c,
None => return PunktfunkStatus::NullPointer, None => return PunktfunkStatus::NullPointer,
@@ -73,142 +73,6 @@ pub(crate) const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
/// FIRST no-op clock flush — the moment a step is actually suspected. /// FIRST no-op clock flush — the moment a step is actually suspected.
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60); pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Standing-latency bleed (the 2026-07 two-pair investigation): how far above the session's own
/// one-way-delay floor a report window's MINIMUM must sit to count as a standing elevation. The
/// jump-to-live detectors above deliberately ignore anything below ~6 frames / 400 ms, so a
/// small standing state — a sub-frame kernel/reassembly backlog, or a stale clock offset after a
/// wall-clock step — is carried forever and reads as permanent extra "network" latency. 10 ms
/// sits above skew-handshake error + normal LAN jitter, and below a single 60 fps frame period,
/// so the observed one-frame plateau (~17 ms) trips it while a healthy stream cannot.
pub(crate) const STANDING_LAT_THRESH_NS: i128 = 10_000_000;
/// Consecutive elevated report windows (~750 ms each) before the bleed escalates — ~4.5 s of a
/// continuously standing, loss-free elevation. Windows with any loss reset the run: loss means
/// genuine congestion, which the FEC/ABR machinery owns, not this detector.
pub(crate) const STANDING_LAT_WINDOWS: u32 = 6;
/// Per-session cap on flush+keyframe bleeds. A standing state that survives a clock re-sync AND
/// this many local flushes is not local and not clock — the path latency itself changed; the
/// detector disarms with a warning instead of paying a recovery keyframe every few seconds.
pub(crate) const STANDING_LAT_MAX_BLEEDS: u32 = 3;
/// What the standing-latency detector asks the pump to do this window (see [`StandingLatency`]).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum StandingLatAction {
None,
/// First escalation: ask for a mid-stream clock re-sync — free, and a stale offset from a
/// stepped/slewed wall clock produces exactly this signature (an applied re-sync re-bases
/// the floor via the pump's `clock_gen` watch, clearing the elevation if that was the cause).
Resync {
above_ms: i64,
},
/// The elevation survived a re-sync attempt: flush the local receive backlog + request a
/// keyframe (the jump-to-live action), draining a real sub-threshold standing queue. The
/// pump reports execution back via [`StandingLatency::bled`]; an unexecuted action simply
/// re-arms next window.
Bleed {
above_ms: i64,
},
/// Bleed cap reached and the elevation is back: give up and say so.
Disarm {
above_ms: i64,
},
}
/// Detector for a small, constant, loss-free one-way-delay elevation — the standing state the
/// jump-to-live thresholds deliberately tolerate. Tracks the session's OWD floor (minimum of
/// report-window minimums since start / last re-base) and escalates when windows sit
/// persistently above it: re-sync first, then a bounded number of flush+keyframe bleeds, then
/// disarm. Pure state machine (no clocks, no I/O) so the escalation ladder is unit-testable.
pub(crate) struct StandingLatency {
/// Lowest window-minimum OWD seen since session start / last [`rebase`](Self::rebase).
floor_ns: Option<i128>,
/// Minimum per-frame OWD this report window; `None` = no frames yet.
window_min_ns: Option<i128>,
/// Consecutive elevated windows.
run: u32,
/// The current elevation already got its re-sync request — next escalation is a bleed.
resync_tried: bool,
bleeds: u32,
disarmed: bool,
}
impl StandingLatency {
pub(crate) fn new() -> Self {
StandingLatency {
floor_ns: None,
window_min_ns: None,
run: 0,
resync_tried: false,
bleeds: 0,
disarmed: false,
}
}
/// Feed one frame's skew-corrected OWD (capture→reassembly-complete, ns). Caller gates on a
/// live clock offset and plausibility (0 < owd < 10 s), like the ABR OWD signal.
pub(crate) fn note_frame(&mut self, owd_ns: i128) {
self.window_min_ns = Some(match self.window_min_ns {
Some(m) => m.min(owd_ns),
None => owd_ns,
});
}
/// Close a report window. `loss_free` = the window carried zero loss (loss resets the run —
/// congestion is the FEC/ABR machinery's problem, and queues under loss are not "standing").
pub(crate) fn on_window(&mut self, loss_free: bool) -> StandingLatAction {
let Some(wmin) = self.window_min_ns.take() else {
return StandingLatAction::None; // no frames this window — no evidence either way
};
let floor = *self.floor_ns.get_or_insert(wmin);
self.floor_ns = Some(floor.min(wmin));
let above_ns = wmin - floor;
if self.disarmed {
return StandingLatAction::None;
}
if !loss_free || above_ns < STANDING_LAT_THRESH_NS {
self.run = 0;
if above_ns < STANDING_LAT_THRESH_NS {
self.resync_tried = false; // elevation cleared — a future one re-syncs first again
}
return StandingLatAction::None;
}
self.run += 1;
if self.run < STANDING_LAT_WINDOWS {
return StandingLatAction::None;
}
self.run = 0; // each escalation gets a fresh observation run
let above_ms = (above_ns / 1_000_000) as i64;
if !self.resync_tried {
self.resync_tried = true;
StandingLatAction::Resync { above_ms }
} else if self.bleeds < STANDING_LAT_MAX_BLEEDS {
StandingLatAction::Bleed { above_ms }
} else {
self.disarmed = true;
StandingLatAction::Disarm { above_ms }
}
}
/// The pump executed a [`StandingLatAction::Bleed`] (flush + keyframe). The floor is KEPT: a
/// successful bleed brings OWD back down to it (elevation clears naturally); an unsuccessful
/// one leaves the elevation visible so the ladder continues toward the cap.
pub(crate) fn bled(&mut self) {
self.bleeds += 1;
self.window_min_ns = None;
}
/// A mid-stream clock re-sync was APPLIED (the pump's `clock_gen` watch): every OWD reading
/// shifted, so the floor and any elevation measured under the old offset are meaningless —
/// re-learn from scratch. The bleed budget survives (it caps keyframes per session).
pub(crate) fn rebase(&mut self) {
self.floor_ns = None;
self.window_min_ns = None;
self.run = 0;
self.resync_tried = false;
}
}
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal. /// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the /// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a /// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
@@ -327,7 +191,6 @@ mod frame_channel_tests {
pts_ns: i as u64, pts_ns: i as u64,
flags: 0, flags: 0,
complete: true, complete: true,
received_ns: 0,
} }
} }
@@ -395,143 +258,3 @@ mod frame_channel_tests {
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32)); assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
} }
} }
#[cfg(test)]
mod standing_latency_tests {
use super::{
StandingLatAction, StandingLatency, STANDING_LAT_MAX_BLEEDS, STANDING_LAT_THRESH_NS,
STANDING_LAT_WINDOWS,
};
const FLOOR: i128 = 2_000_000; // a healthy 2 ms LAN OWD
const ELEVATED: i128 = FLOOR + STANDING_LAT_THRESH_NS + 7_000_000; // ~one 60fps frame above
/// Run `n` windows at `owd`, asserting every window but the last returns None; returns the
/// last window's action.
fn run_windows(d: &mut StandingLatency, owd: i128, n: u32) -> StandingLatAction {
for i in 0..n {
d.note_frame(owd);
let a = d.on_window(true);
if i + 1 < n {
assert_eq!(a, StandingLatAction::None, "window {i} escalated early");
} else {
return a;
}
}
unreachable!("n > 0 by construction");
}
/// Learn a clean floor: one window at the healthy OWD.
fn learned(d: &mut StandingLatency) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn healthy_stream_never_escalates() {
let mut d = StandingLatency::new();
learned(&mut d);
// Jitter riding above the floor but under the threshold: never a run.
for _ in 0..(STANDING_LAT_WINDOWS * 4) {
d.note_frame(FLOOR + STANDING_LAT_THRESH_NS - 1);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn escalation_ladder_resync_then_bleeds_then_disarm() {
let mut d = StandingLatency::new();
learned(&mut d);
// First full elevated run asks for the free fix: a clock re-sync.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// Re-sync didn't help (no rebase came) — each further run is a bleed, up to the cap...
for _ in 0..STANDING_LAT_MAX_BLEEDS {
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Bleed { .. }
));
d.bled();
}
// ...then the detector gives up loudly, once, and stays quiet.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Disarm { .. }
));
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn loss_windows_reset_the_run() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A lossy window means congestion, not a standing state: run resets...
d.note_frame(ELEVATED);
assert_eq!(d.on_window(false), StandingLatAction::None);
// ...so the ladder needs the full run again before acting.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn recovery_resets_the_ladder_to_resync_first() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The elevation clears on its own (e.g. the successful bleed case, or transient): the
// next episode starts back at the free escalation, not at a bleed.
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn applied_resync_rebases_and_clears_a_stale_offset_elevation() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The re-sync APPLIES (pump sees clock_gen move) → rebase. The corrected offset brings
// OWD readings back to truth; the floor re-learns and nothing ever escalates to a bleed.
d.rebase();
for _ in 0..(STANDING_LAT_WINDOWS * 2) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn empty_windows_are_no_evidence() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A frameless window (paused stream) neither advances nor resets the run...
assert_eq!(d.on_window(true), StandingLatAction::None);
// ...so one more elevated window completes it.
d.note_frame(ELEVATED);
assert!(matches!(
d.on_window(true),
StandingLatAction::Resync { .. }
));
}
}
+6 -29
View File
@@ -393,7 +393,6 @@ impl NativeClient {
launch, launch,
pin, pin,
identity, identity,
connect_timeout: timeout,
frames: frame_chan_w, frames: frame_chan_w,
audio_tx, audio_tx,
rumble_tx, rumble_tx,
@@ -426,12 +425,6 @@ impl NativeClient {
Ok(Ok(t)) => t, Ok(Ok(t)) => t,
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
// A connect we already reported as failed must not leave a lingering host
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
// drop / close code 0) so the worker's close tells the host to tear down now
// instead of holding the session (and its virtual display) for a reconnect
// that will never come.
quit.store(true, Ordering::SeqCst);
shutdown.store(true, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst);
return Err(PunktfunkError::Timeout); return Err(PunktfunkError::Timeout);
} }
@@ -463,14 +456,9 @@ impl NativeClient {
hot_tids, hot_tids,
clock_offset, clock_offset,
decode_lat, decode_lat,
// The controller arms exactly when the pump does — all three terms, not two: Automatic // The controller arms exactly when the pump does (see `abr::BitrateController::new`
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host // below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
// echoed the rate it actually configured. Dropping the last term made this wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
// over-advertise against an old host that reports no rate, so an embedder fed decode
// latency to a controller that never runs.
wants_decode: bitrate_kbps == 0
&& negotiated.codec != crate::quic::CODEC_PYROWAVE
&& negotiated.bitrate_kbps > 0,
mode: mode_slot, mode: mode_slot,
host_fingerprint: negotiated.host_fingerprint, host_fingerprint: negotiated.host_fingerprint,
resolved_compositor: negotiated.compositor, resolved_compositor: negotiated.compositor,
@@ -715,23 +703,14 @@ impl NativeClient {
// Reset the accumulator so a fresh run doesn't blend into the previous one. // Reset the accumulator so a fresh run doesn't blend into the previous one.
*self.probe.lock().unwrap() = ProbeState { *self.probe.lock().unwrap() = ProbeState {
active: true, active: true,
duration_ms,
..Default::default() ..Default::default()
}; };
let sent = self self.ctrl_tx
.ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest { .try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps, target_kbps,
duration_ms, duration_ms,
})) }))
.map_err(|_| PunktfunkError::Closed); .map_err(|_| PunktfunkError::Closed)
if sent.is_err() {
// Nothing was asked of the host, so nothing will ever answer. Leaving `active` latched
// would suppress the pump's entire report tick for the rest of the session (the pump
// mirrors the startup path's rollback at the same point).
self.probe.lock().unwrap().active = false;
}
sent
} }
/// Read the current speed-test measurement (partial until `done`, final once the host's /// Read the current speed-test measurement (partial until `done`, final once the host's
@@ -766,9 +745,7 @@ impl NativeClient {
0.0 0.0
} as f32; } as f32;
// Host-side drop: what the send buffer couldn't even accept (the host-side ceiling). // Host-side drop: what the send buffer couldn't even accept (the host-side ceiling).
// Saturating: both counters arrive verbatim off the wire (same discipline as the let offered_wire = p.host_wire_packets + p.host_send_dropped;
// saturating_sub/mul above — a hostile sum must not overflow-panic a debug build).
let offered_wire = p.host_wire_packets.saturating_add(p.host_send_dropped);
let host_drop_pct = if offered_wire > 0 { let host_drop_pct = if offered_wire > 0 {
p.host_send_dropped as f64 / offered_wire as f64 * 100.0 p.host_send_dropped as f64 / offered_wire as f64 * 100.0
} else { } else {
@@ -32,11 +32,6 @@ pub(crate) struct ProbeState {
pub(crate) host_duration_ms: u32, pub(crate) host_duration_ms: u32,
/// The host's `ProbeResult` arrived → the measurement is final. /// The host's `ProbeResult` arrived → the measurement is final.
pub(crate) done: bool, pub(crate) done: bool,
/// The requested burst length, so the pump can arm a watchdog for a host that never answers.
/// Without one, an ignored `ProbeRequest` latches `active` forever and the pump's whole report
/// tick — loss reports, the ABR window feed, the standing-latency ladder and pending clock
/// re-syncs — stays suppressed for the rest of the session.
pub(crate) duration_ms: u32,
} }
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`]. /// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
File diff suppressed because it is too large Load Diff
@@ -1,181 +0,0 @@
//! Control task: the handshake stream stays open for mid-stream renegotiation + speed tests.
//! Outbound requests (mode switch, probe) and inbound replies (Reconfigured, ProbeResult) are
//! multiplexed with `select!`; a single outbound channel (`ctrl_rx`) keeps one writer so the
//! two `&mut ctrl_send` borrows don't collide across branches.
use super::super::*;
use super::*;
pub(super) struct ControlTask {
pub(super) ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
pub(super) ctrl_send: quinn::SendStream,
pub(super) ctrl_recv: io::MsgReader,
/// `None` = no connect-time skew handshake (old host) — clock re-sync stays off.
pub(super) clock_rtt_ns: Option<u64>,
pub(super) mode_slot: Arc<Mutex<Mode>>,
pub(super) probe: Arc<Mutex<ProbeState>>,
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
pub(super) clock_gen: Arc<AtomicU32>,
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
/// clipboard task uses for fetch data.
pub(super) clip_event_tx: std::sync::mpsc::SyncSender<ClipEventCore>,
}
impl ControlTask {
pub(super) async fn run(self) {
let ControlTask {
mut ctrl_rx,
mut ctrl_send,
mut ctrl_recv,
clock_rtt_ns,
mode_slot,
probe,
bitrate_ack,
clock_offset,
clock_gen,
clip_event_tx,
} = self;
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
// its first no-op clock flush). Echoes interleave with the other control replies in
// the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes.
let mut resync = ClockResync::new();
let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL,
);
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
req = ctrl_rx.recv() => {
let Some(req) = req else { break }; // client dropped
let bytes = match req {
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
CtrlRequest::Probe(p) => p.encode(),
CtrlRequest::Keyframe => RequestKeyframe.encode(),
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer
}
resync.begin(wall_clock_ns()).encode()
}
CtrlRequest::ClipControl(c) => c.encode(),
CtrlRequest::ClipOffer(o) => o.encode(),
};
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break;
}
}
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = ctrl_recv.read_msg() => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) {
if ack.accepted {
*mode_slot.lock().unwrap() = ack.mode;
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
} else {
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
}
} else if let Ok(result) = ProbeResult::decode(&msg) {
let mut p = probe.lock().unwrap();
// Freeze the delivered figures now (the burst is done), before resumed
// video can inflate the packet counters.
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
p.host_goodput_bytes = result.bytes_sent;
p.host_au = result.packets_sent;
p.host_wire_packets = result.wire_packets_sent;
p.host_send_dropped = result.send_dropped;
p.host_duration_ms = result.duration_ms;
p.done = true;
p.active = false; // burst over — the pump stops mirroring counters
tracing::info!(
host_goodput_bytes = result.bytes_sent,
wire_packets_sent = result.wire_packets_sent,
send_dropped = result.send_dropped,
duration_ms = result.duration_ms,
delivered_packets = p.delivered_packets,
"speed-test probe result"
);
} else if let Ok(ack) = BitrateChanged::decode(&msg) {
// Adaptive bitrate: the host's clamp is authoritative — park it for
// the pump's controller (which also reads any ack as "this host
// renegotiates", arming further steps).
tracing::info!(
kbps = ack.bitrate_kbps,
"host re-targeted encoder bitrate"
);
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
break;
}
}
ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read
// late exactly then) — keep the old estimate and let the next
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
// info, not debug: ≤1/min, and it is THE forensic
// trail for a stale-offset (stepped/slewed wall clock)
// latency plateau — the 2026-07 two-pair investigation
// had to reconstruct this blind.
tracing::info!(
offset_ns,
rtt_us = rtt_ns / 1000,
"mid-stream clock re-sync applied"
);
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
} else {
tracing::info!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
}
}
ResyncStep::Idle => {}
}
} else if let Ok(state) = ClipState::decode(&msg) {
// Host ack / policy / backend update for the toggle UI (try_send: a
// lagging embedder drops the newest — a stale toggle heals on the next).
let _ = clip_event_tx.try_send(ClipEventCore::State {
enabled: state.enabled,
policy: state.policy,
reason: state.reason,
});
} else if let Ok(offer) = ClipOffer::decode(&msg) {
// The host copied something: surface the lazy format list; the embedder
// fetches only if a local app pastes.
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
seq: offer.seq,
kinds: offer.kinds,
});
} else {
tracing::warn!(
tag = ?msg.first(),
len = msg.len(),
"unknown control message — ignoring"
);
}
}
}
}
}
}
@@ -1,568 +0,0 @@
//! The blocking data-plane pump: poll the session for access units, run the adaptive-FEC
//! loss reports, the ABR controller + startup capacity probe, the jump-to-live detectors,
//! and the standing-latency bleed, and hand frames to the embedder.
use super::super::*;
use super::*;
/// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
/// try_send drops the newest frame when the embedder lags (freshness over completeness).
/// Speed-test filler ([`FLAG_PROBE`]) is folded into the probe accumulator instead of the
/// decoder queue — it isn't video.
pub(super) struct DataPump {
pub(super) session: Session,
pub(super) frames: Arc<FrameChannel>,
pub(super) ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
pub(super) shutdown: Arc<std::sync::atomic::AtomicBool>,
pub(super) probe: Arc<Mutex<ProbeState>>,
pub(super) hot_tids: Arc<Mutex<Vec<i32>>>,
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
pub(super) clock_gen: Arc<AtomicU32>,
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
pub(super) bitrate_kbps: u32,
/// The rate the host actually configured (echoed in Welcome).
pub(super) resolved_bitrate_kbps: u32,
pub(super) negotiated_codec: u8,
}
impl DataPump {
pub(super) fn run(self) {
let DataPump {
mut session,
frames,
ctrl_tx,
shutdown: pump_shutdown,
probe: pump_probe,
hot_tids: pump_hot_tids,
clock_offset: pump_clock_offset,
clock_gen: pump_clock_gen,
decode_lat: pump_decode_lat,
frames_dropped,
fec_recovered,
bitrate_ack,
bitrate_kbps,
resolved_bitrate_kbps,
negotiated_codec,
} = self;
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
let (
mut last_recovered,
mut last_late,
mut last_received,
mut last_dropped,
mut last_bytes,
) = (0u64, 0u64, 0u64, 0u64, 0u64);
// PUNKTFUNK_PERF: per-window pump observability — the Session's receive stage split
// (recv / decrypt / reassemble+FEC, see `Session::take_pump_perf`) and completed-AU
// inter-arrival jitter. Smoothness has no metric otherwise: jump-to-live counters only
// fire after the stream is already seconds behind.
let pump_perf_on = std::env::var("PUNKTFUNK_PERF").is_ok_and(|v| v != "0");
let mut arrivals_us: Vec<u32> = Vec::new();
let mut last_arrival: Option<Instant> = None;
// Adaptive bitrate (see `crate::abr`): armed only when the embedder asked for Automatic
// (`bitrate_kbps == 0`) and the host echoed the rate it actually configured (an old host
// echoes 0 → controller stays permanently off). Fed once per report window with the same
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay, the
// actual delivered throughput (climb gate + proven-throughput mark), and whether a
// jump-to-live flush fired.
// PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps
} else {
0
});
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
// could NEVER climb out of. One speed-test burst shortly after the stream settles
// measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance)
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
// reply) or never answer (timeout clears the state so LossReports resume) — either way
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
const CAPACITY_PROBE_MS: u32 = 800;
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
&& !rate_pinned
&& resolved_bitrate_kbps > 0
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
let mut capacity_probe_deadline: Option<Instant> = None;
// Edge detector + watchdog for a probe of EITHER origin (the startup capacity probe or an
// embedder speed test via `NativeClient::request_probe`). The startup path had both built
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
let mut probe_watchdog: Option<Instant> = None;
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
let mut flush_in_window = false;
// Jump-to-live state (see the guard in the loop below): when the clock-based over-bound
// run began (`stale_since`, armed only when the skew handshake succeeded so the clocks
// are comparable), when the clock-free non-draining-queue run began (`standing_since`),
// and the last-jump instant for the shared cooldown. Wall-clock runs (T1.4), not frame
// counts — the detectors' sensitivity must not scale with fps or repeat cadence.
let mut stale_since: Option<Instant> = None;
let mut standing_since: Option<Instant> = None;
let mut last_flush: Option<Instant> = None;
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
// the control task for an immediate re-sync (via the report tick): the flush finding no
// local backlog IS the "the wall clock stepped under me" signal.
let mut noop_clock_flushes: u32 = 0;
let mut clock_detector_armed = true;
let mut resync_wanted = false;
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
// backlog, or a stale clock offset after a wall-clock step, either of which otherwise
// reads as permanent extra "network" latency for the rest of the session.
let mut standing_lat = StandingLatency::new();
while !pump_shutdown.load(Ordering::SeqCst) {
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
// re-sync takes effect on the very next frame's latency math.
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
// An applied re-sync invalidates the staleness run measured under the OLD offset:
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
let gen = pump_clock_gen.load(Ordering::Relaxed);
if gen != seen_clock_gen {
seen_clock_gen = gen;
stale_since = None;
noop_clock_flushes = 0;
// Every OWD reading shifted with the offset — the standing-latency floor and
// any elevation measured under the old one are meaningless now. If a stale
// offset WAS the elevation, this is also the moment it gets fixed.
standing_lat.rebase();
if !clock_detector_armed {
clock_detector_armed = true;
tracing::info!("clock re-sync applied — clock-based jump-to-live re-armed");
}
}
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput
// measurement. Updated every iteration (not just on a produced frame) so they stay current
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
let st = session.stats();
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
let probe_active = {
let mut p = pump_probe.lock().unwrap();
if p.active && !p.done {
p.rx_packets_now = st.packets_received;
p.rx_bytes_now = st.bytes_received;
p.base_packets.get_or_insert(st.packets_received);
p.base_bytes.get_or_insert(st.bytes_received);
}
p.active && !p.done
};
// A probe just ended (either kind): rebase EVERY window anchor past the burst. Its
// FLAG_PROBE filler landed in `bytes_received`/`packets_received` (session.rs counts
// every accepted datagram) but never reached the decoder, and the report tick was
// suppressed for the whole burst, so `last_*` still points before it. Without this the
// first post-burst window reads the burst rate as `actual_kbps` and poisons the ABR's
// monotone proven-throughput high-water mark — which never decays — and divides the
// window's loss by a packet count inflated with filler.
if was_probing && !probe_active {
last_recovered = st.fec_recovered_shards;
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_report = Instant::now();
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
// cannot latch `active` forever and suppress the report tick for the whole session.
if !was_probing && probe_active {
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
}
if !probe_active {
probe_watchdog = None;
} else if let Some(deadline) = probe_watchdog {
if Instant::now() >= deadline {
probe_watchdog = None;
pump_probe.lock().unwrap().active = false;
tracing::warn!(
"speed-test probe unanswered — clearing it so loss reports and ABR resume"
);
}
}
was_probing = probe_active;
// Fire the startup link-capacity probe once the stream has settled (see the constants
// above), and fold its measurement into the ABR ceiling when the result lands.
// Never steal the slot from an embedder speed test in flight: there is one `ProbeState`
// and no correlation id, so a clobber both wrecks the user's "Test connection" figure
// (its base counters get re-snapshotted mid-burst against the full-burst denominator)
// and mis-scales our own ceiling. Retry once it finishes.
if capacity_probe_at.is_some_and(|at| Instant::now() >= at) && probe_active {
capacity_probe_at = Some(Instant::now() + CAPACITY_PROBE_DELAY);
} else if capacity_probe_at.is_some_and(|at| Instant::now() >= at) {
capacity_probe_at = None;
*pump_probe.lock().unwrap() = ProbeState {
active: true,
duration_ms: CAPACITY_PROBE_MS,
..Default::default()
};
if ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps: CAPACITY_PROBE_KBPS,
duration_ms: CAPACITY_PROBE_MS,
}))
.is_ok()
{
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
tracing::info!(
target_kbps = CAPACITY_PROBE_KBPS,
duration_ms = CAPACITY_PROBE_MS,
"adaptive bitrate: startup link-capacity probe"
);
} else {
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
}
}
if let Some(deadline) = capacity_probe_deadline {
let mut p = pump_probe.lock().unwrap();
if p.done {
capacity_probe_deadline = None;
// An all-zero reply is a decline (old host / probe-less build) — keep the
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
/ p.host_duration_ms.max(1) as u64)
as u32;
let ceiling = delivered_kbps.saturating_mul(7) / 10;
abr.set_ceiling(ceiling);
tracing::info!(
delivered_kbps,
ceiling_kbps = ceiling,
"adaptive bitrate: link-capacity probe done — climb ceiling set"
);
} else {
tracing::info!(
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
);
}
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
// the decoder — rebase the ABR window's byte counter past it, or the next
// window's "actual throughput" reads as the burst rate and poisons the
// controller's proven-throughput high-water mark with the LINK rate.
last_bytes = st.bytes_received;
} else if Instant::now() >= deadline {
// The host never answered (a build that ignores ProbeRequest): clear the
// stuck-active state so LossReports resume, keep the negotiated ceiling.
p.active = false;
capacity_probe_deadline = None;
tracing::info!(
"adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling"
);
}
}
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
if resync_wanted {
resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered),
st.fec_late_shards.wrapping_sub(last_late),
st.packets_received.wrapping_sub(last_received),
window_dropped,
);
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
// from a stepped wall clock produces exactly this signature and the applied
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
// loud disarm (the path latency itself changed; nothing local fixes that).
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
StandingLatAction::None => {}
StandingLatAction::Resync { above_ms } => {
tracing::info!(
above_ms,
"standing latency above the session floor with zero loss — \
requesting a clock re-sync first (a stale offset reads exactly \
like this)"
);
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
StandingLatAction::Bleed { above_ms } => {
// Shares the jump-to-live cooldown: an unexecuted bleed simply re-arms
// over the next windows (the detector's run rebuilds).
if last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) {
last_flush = Some(Instant::now());
// Deliberately NOT `flush_in_window = true`: that flag is the ABR's
// SEVERE verdict (an immediate ×0.7 back-off), and the bleed fires
// only after ~6 provably loss-free windows with a sub-25ms elevation
// the controller itself scores as fine. The bleed's effect reaches
// the ABR through the window's own honest signals (OWD/loss/decode);
// the flag stays exclusive to the jump-to-live path below.
let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear();
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
standing_lat.bled();
tracing::warn!(
above_ms,
flushed_datagrams = flushed,
dropped_frames = dropped,
"standing latency survived a clock re-sync — bled the local \
backlog (flush + keyframe)"
);
}
}
StandingLatAction::Disarm { above_ms } => {
tracing::warn!(
above_ms,
"standing latency persists after a re-sync and every bleed — not \
local, not clock; the path latency changed. Leaving it be \
(reconnect re-baselines)"
);
}
}
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
// feed the controller this window's congestion signals; a decision becomes a
// SetBitrate on the control stream.
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
abr.on_ack(acked);
}
let owd_mean_us =
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
(owd_sum_ns, owd_frames) = (0, 0);
// Drain the embedder's decode-latency window (always, so it stays bounded even when
// the controller is disabled) → the mean feeds the decode signal; `None` when the
// embedder reported nothing this window (old embedder / no decoded frames).
let decode_mean_us = {
let mut acc = pump_decode_lat.lock().unwrap();
let (sum, count) = (acc.sum_us, acc.count);
*acc = DecodeLatAcc::default();
(count > 0).then(|| (sum / count as u64) as i64)
};
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
// semantics (both compare against targets with their own headroom).
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
/ window_ms) as u32;
if let Some(kbps) = abr.on_window(
Instant::now(),
window_dropped,
loss_ppm,
owd_mean_us,
decode_mean_us,
actual_kbps,
flush_in_window,
) {
// Log the window's signals alongside the decision so an on-glass session can
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
// loss/OWD flat) from a network-driven one.
tracing::info!(
kbps,
loss_ppm,
owd_mean_us = owd_mean_us.unwrap_or(-1),
decode_mean_us = decode_mean_us.unwrap_or(-1),
actual_kbps,
flushed = flush_in_window,
"adaptive bitrate: requesting encoder re-target"
);
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
}
flush_in_window = false;
last_report = Instant::now();
last_recovered = st.fec_recovered_shards;
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
if pump_perf_on {
if let Some(p) = session.take_pump_perf() {
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
tracing::info!(
recv_ms = p.recv_ns / 1_000_000,
decrypt_ms = p.decrypt_ns / 1_000_000,
reasm_ms = p.reasm_ns / 1_000_000,
packets = p.packets,
batches = p.batches,
pkts_per_batch = p.packets.checked_div(p.batches).unwrap_or(0),
decrypt_ns_pkt = per_pkt_ns(p.decrypt_ns),
reasm_ns_pkt = per_pkt_ns(p.reasm_ns),
"pump stage split (window)"
);
}
// Inter-arrival jitter over the window's completed AUs. `late` counts gaps
// over 2× the window median — the "a frame arrived visibly off-beat" tally.
if arrivals_us.len() >= 8 {
arrivals_us.sort_unstable();
let pct = |q: usize| arrivals_us[(arrivals_us.len() - 1) * q / 100];
let (p50, p95) = (pct(50), pct(95));
let late = arrivals_us.iter().filter(|&&d| d > p50 * 2).count();
tracing::info!(
frames = arrivals_us.len() + 1,
arrival_p50_us = p50,
arrival_p95_us = p95,
arrival_max_us = arrivals_us.last().copied().unwrap_or(0),
late,
"frame inter-arrival jitter (window)"
);
}
arrivals_us.clear();
}
}
match session.poll_frame() {
Ok(frame) => {
if frame.flags & FLAG_PROBE as u32 != 0 {
continue; // speed-test filler, not video — measured via the counters above
}
if pump_perf_on {
let now = Instant::now();
if let Some(prev) = last_arrival.replace(now) {
// 4096 ≈ 17 s at 240 fps — a stuck window can't grow it unbounded.
if arrivals_us.len() < 4096 {
arrivals_us
.push((now - prev).as_micros().min(u32::MAX as u128) as u32);
}
}
}
// Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
// the pump consumes strictly in order at the arrival rate, so once behind, the
// stream stays behind for good (observed live: stuck 67 s). Pre-decode AUs are
// reference-chained (infinite GOP), so we can NOT drop a frame mid-stream to catch
// up; the only safe recovery is to discard the whole backlog and re-anchor decode
// on a fresh keyframe. Two independent "we're behind" signals arm it, both gated by
// FLUSH_COOLDOWN, both suspended during a speed test (the probe MEASURES a saturated
// queue; flushing would corrupt its counters):
// * clock-based — completed frames sit > FLUSH_LATENCY behind the skew-corrected
// capture clock continuously for FLUSH_AFTER. Needs the skew handshake, and
// also catches kernel/reassembler backlog the hand-off queue hasn't reached yet.
// * clock-free — the pre-decode hand-off queue stopped draining: its depth stayed
// ≥ QUEUE_HIGH (never falling to QUEUE_LOW, still high at the trip) for
// STANDING_TIME. Works with no handshake / a same-clock session (where the
// clock path is disarmed), and is the direct signal that the embedder can't
// keep up. A transient Wi-Fi clump drains within ~100 ms and never trips it.
if probe_active {
// Keep both detectors disarmed across a speed test so its (deliberately)
// saturated queue doesn't leave a primed run that fires the moment it ends.
stale_since = None;
standing_since = None;
} else {
let lat_ns = if clock_offset_ns != 0 {
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128
} else {
0
};
// Feed the adaptive-bitrate controller's OWD window (mean capture→received
// delay): rising delay under zero loss is queue growth — the pre-loss
// congestion signal. Only meaningful with a clock handshake.
if clock_offset_ns != 0 && lat_ns > 0 {
owd_sum_ns += lat_ns;
owd_frames += 1;
// The standing-latency detector rides the same signal, but off the
// window MINIMUM (robust against jitter/burst spikes — a standing
// state elevates the floor itself). Same 10 s plausibility clamp as
// the hn stats use.
if lat_ns < 10_000_000_000 {
standing_lat.note_frame(lat_ns);
}
}
if clock_detector_armed
&& clock_offset_ns != 0
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
{
stale_since.get_or_insert_with(Instant::now);
} else {
stale_since = None;
}
let depth = frames.depth();
if depth >= QUEUE_HIGH {
standing_since.get_or_insert_with(Instant::now);
} else if depth <= QUEUE_LOW {
standing_since = None;
}
// The queue trip additionally requires the depth to still be high NOW, so
// a run that started ≥ high but is hovering in the hysteresis band (a
// clump mid-drain) never fires on elapsed time alone.
let clock_behind = stale_since.is_some_and(|t| t.elapsed() >= FLUSH_AFTER);
let queue_behind = depth >= QUEUE_HIGH
&& standing_since.is_some_and(|t| t.elapsed() >= STANDING_TIME);
if (clock_behind || queue_behind)
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
{
stale_since = None;
standing_since = None;
last_flush = Some(Instant::now());
flush_in_window = true; // strongest "link can't hold the rate" signal
let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear();
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
tracing::warn!(
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
queue_depth = depth,
flushed_datagrams = flushed,
dropped_frames = dropped,
"receive backlog stopped draining — jumped to live (flush + keyframe)"
);
// Clock-detector health check: a clock-only trigger whose flush found
// no local backlog is a false "behind" reading (a wall-clock step, or
// an upstream queue a local flush can't drain) — repeated, it would
// cost a recovery IDR every cooldown forever. Disarm after two in a
// row; the clock-free queue detector keeps covering real backlogs.
if clock_behind
&& !queue_behind
&& flushed < NOOP_FLUSH_DATAGRAMS
&& dropped == 0
{
noop_clock_flushes += 1;
if noop_clock_flushes == 1 {
// First no-op flush = a wall-clock step is the prime
// suspect: ask for an immediate re-sync (sent on the next
// report tick). Applied, it resets these counters and
// re-arms the detector before the disarm below triggers.
resync_wanted = true;
}
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
clock_detector_armed = false;
tracing::warn!(
"clock-based jump-to-live disarmed — its flushes found no \
local backlog (clock step or upstream queueing suspected); \
the queue-depth detector stays armed"
);
}
} else {
noop_clock_flushes = 0;
}
continue; // this frame is part of the stale past — don't render it
}
}
frames.push(frame);
}
Err(PunktfunkError::NoFrame) => {
std::thread::sleep(Duration::from_micros(300));
}
Err(_) => break,
}
}
// The pump exited (shutdown / fatal session error) — wake any consumer blocked in
// `next_frame` with a Closed signal instead of a spurious timeout (the old mpsc did this
// implicitly when the sender dropped).
frames.close();
}
}
@@ -1,79 +0,0 @@
//! Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
//! newest packet rather than backing up the QUIC receive path).
use super::*;
pub(super) async fn run(
conn: quinn::Connection,
audio_tx: std::sync::mpsc::SyncSender<AudioPacket>,
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
rumble_feed: super::super::rumble::RumbleFeed,
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
) {
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
while let Ok(d) = conn.read_datagram().await {
match d.first() {
Some(&crate::quic::AUDIO_MAGIC) => {
if let Some((seq, pts_ns, opus)) = crate::quic::decode_audio_datagram(&d) {
let _ = audio_tx.try_send(AudioPacket {
seq,
pts_ns,
data: opus.to_vec(),
});
}
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
} else {
true // out-of-range pad (host never sends these): no gate
}
}
None => true,
};
if fresh {
let ttl = u.envelope.map(|e| e.ttl_ms);
// Both consumers are fed; an embedder drains exactly one of them
// (the legacy queue, or the policy engine's command API).
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
}
}
}
Some(&crate::quic::HIDOUT_MAGIC) => {
if let Some(h) = HidOutput::decode(&d) {
let _ = hidout_tx.try_send(h);
}
}
Some(&crate::quic::HDR_META_MAGIC) => {
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
let _ = hdr_meta_tx.try_send(m);
}
}
Some(&crate::quic::HOST_TIMING_MAGIC) => {
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
let _ = host_timing_tx.try_send(t);
}
}
_ => {} // unknown tag — a newer host; ignore
}
}
}
@@ -1,248 +0,0 @@
//! Connect + handshake: dial the host (cert-pinned), exchange Hello/Welcome/Start on the
//! control stream, run the wall-clock skew handshake, hole-punch the data port, and stand
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
use super::super::*;
use super::*;
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
pub(super) struct HandshakeOut {
pub(super) conn: quinn::Connection,
pub(super) session: Session,
pub(super) ctrl_send: quinn::SendStream,
pub(super) ctrl_recv: io::MsgReader,
pub(super) negotiated: Negotiated,
pub(super) host_caps: u8,
}
pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<HandshakeOut> {
let (host, port, pin) = (&args.host, args.port, args.pin);
let (mode, compositor, gamepad) = (args.mode, args.compositor, args.gamepad);
let (bitrate_kbps, video_caps, audio_channels) =
(args.bitrate_kbps, args.video_caps, args.audio_channels);
let (video_codecs, preferred_codec, display_hdr) =
(args.video_codecs, args.preferred_codec, args.display_hdr);
let (launch, identity, shutdown) = (&args.launch, &args.identity, &args.shutdown);
let remote: std::net::SocketAddr = join_host_port(host, port)
.parse()
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
let (ep, observed) = endpoint::client_pinned_with_identity(
pin,
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
);
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
// Dial with retry across the connect budget, not a single attempt: one quinn dial gives
// up after the transport's idle window (~8 s of silence), which is shorter than a
// suspend-to-RAM resume — the Steam Deck flow fires Wake-on-LAN and connects
// immediately, so the host is still waking while the first Initials go out, and a
// single-shot dial died just before the host came up. Short attempts keep the Initial
// cadence dense (quinn's per-attempt retransmits back off toward multi-second gaps), so
// the connect lands within ~a second of the host's network returning. Only SILENCE is
// retried: a host that answers and rejects us (pin mismatch, ALPN/version, typed close)
// must surface immediately, and the embedder's shutdown flag (budget expiry in
// `connect`, or a user cancel) stops the loop between attempts.
const DIAL_ATTEMPT: std::time::Duration = std::time::Duration::from_secs(3);
// Redial headroom: leave room for the control handshake (Hello/Welcome/clock sync)
// after a late dial success, so it still completes inside the embedder's budget.
const CONTROL_HEADROOM: std::time::Duration = std::time::Duration::from_secs(2);
let start = tokio::time::Instant::now();
let deadline = start + args.connect_timeout;
let redial_until = start + args.connect_timeout.saturating_sub(CONTROL_HEADROOM);
let conn = loop {
let connecting = ep
.connect(remote, "punktfunk")
.map_err(|_| PunktfunkError::InvalidArg("connect"))?;
// Cap the attempt to the remaining budget so a success never lands after the
// embedder's `ready_rx` wait has already given up and flagged a teardown.
let now = tokio::time::Instant::now();
let attempt = DIAL_ATTEMPT.min(deadline.saturating_duration_since(now));
let gave_up = || {
tokio::time::Instant::now() >= redial_until
|| shutdown.load(std::sync::atomic::Ordering::SeqCst)
};
match tokio::time::timeout(attempt, connecting).await {
Ok(Ok(conn)) => break conn,
Ok(Err(e)) => {
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
// the embedder can distinguish "wrong host identity" from plain IO trouble.
let fp_mismatch = pin.is_some()
&& observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
if fp_mismatch {
return Err(PunktfunkError::Crypto);
}
// The transport's own idle expiry — the host never answered — is the one
// retryable outcome; everything else is a real answer or a local failure.
let host_silent = matches!(e, quinn::ConnectionError::TimedOut);
if !host_silent {
return Err(PunktfunkError::Io(std::io::Error::other(e.to_string())));
}
if gave_up() {
return Err(PunktfunkError::Timeout);
}
}
// Attempt window elapsed with the host still silent; dropping `connecting`
// abandoned that dial — go again unless the budget is spent.
Err(_) => {
if gave_up() {
return Err(PunktfunkError::Timeout);
}
}
}
tracing::debug!(%remote, "host silent — re-dialing (wake/resume tolerant connect)");
};
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
// The rest of the handshake runs in an inner future so a failure can consult
// `conn.close_reason()`: a host that turned us away with a typed application close
// (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
// read produces — the difference between "not accepted" and the actual cause.
let handshake = async {
let (mut send, recv) = conn
.open_bi()
.await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
// Frame every read on this stream through the resumable reader: the control loop
// below drives it from a `select!` arm and `clock_sync` wraps it in a timeout, and a
// partial frame lost to either would misalign the stream for the whole session.
let mut recv = io::MsgReader::new(recv);
io::write_msg(
&mut send,
&Hello {
abi_version: crate::WIRE_VERSION,
mode,
compositor,
gamepad,
bitrate_kbps,
// No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list.
name: None,
// Library id to launch this session, if the embedder asked for one.
launch: launch.clone(),
// The embedder's decode/present caps (e.g. the Windows client advertises
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
// bit only asks the host for observability datagrams (never changes the encode).
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
// (every embedder inherits it), so the host may burst speed tests without consuming
// video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
video_caps: video_caps
| crate::quic::VIDEO_CAP_HOST_TIMING
| crate::quic::VIDEO_CAP_PROBE_SEQ
| crate::quic::VIDEO_CAP_STREAMED_AU,
// Requested surround channel count; the host echoes the resolved value in Welcome.
audio_channels,
// The codecs this client can decode + its soft preference (0 = auto). The host
// resolves the emitted codec from these and reports it in `Welcome::codec`.
video_codecs,
preferred_codec,
// The client display's HDR volume → the host's virtual-display EDID (host apps
// tone-map to the client's real panel). `None` = unknown/SDR.
display_hdr,
}
.encode(),
)
.await?;
let welcome = Welcome::decode(&recv.read_msg().await?)?;
if welcome.compositor != CompositorPref::Auto {
tracing::info!(
compositor = welcome.compositor.as_str(),
"host resolved compositor"
);
}
if welcome.gamepad != GamepadPref::Auto {
tracing::info!(
gamepad = welcome.gamepad.as_str(),
"host resolved gamepad backend"
);
}
// Reserve our data-plane port, then start the host.
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
let udp_port = probe.local_addr()?.port();
drop(probe);
io::write_msg(
&mut send,
&Start {
client_udp_port: udp_port,
}
.encode(),
)
.await?;
// Wall-clock skew handshake on the control stream (before the session's control task takes
// it): align our clock to the host's so the embedder can express receive/present instants in
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
let (clock_offset_ns, clock_rtt_ns) =
match crate::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => {
tracing::info!(
offset_ns = skew.offset_ns,
rtt_us = skew.rtt_ns / 1000,
rounds = skew.rounds,
"clock skew estimated (host-client)"
);
(skew.offset_ns, Some(skew.rtt_ns))
}
None => (0, None),
};
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
let transport =
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
// to open the path first). Stops with the session via the shared shutdown flag.
if let Ok(sock) = transport.try_clone_socket() {
crate::transport::spawn_data_punch(sock, shutdown.clone());
}
let mut session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
// PyroWave sessions opt into partial delivery (plan §4.4): an aged-out lossy
// frame arrives as blocks-with-holes instead of vanishing — the all-intra codec
// renders it as one frame of localized blur, strictly better than a freeze.
if welcome.codec == crate::quic::CODEC_PYROWAVE {
session.set_deliver_partial_frames(true);
}
Ok::<_, PunktfunkError>((
session,
send,
recv,
Negotiated {
mode: welcome.mode,
compositor: welcome.compositor,
gamepad: welcome.gamepad,
host_fingerprint: fingerprint,
bitrate_kbps: welcome.bitrate_kbps,
clock_offset_ns,
clock_rtt_ns,
bit_depth: welcome.bit_depth,
color: welcome.color,
chroma_format: welcome.chroma_format,
audio_channels: welcome.audio_channels,
codec: welcome.codec,
shard_payload: welcome.shard_payload,
host_caps: welcome.host_caps,
},
welcome.host_caps,
))
};
match handshake.await {
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
conn,
session,
ctrl_send: send,
ctrl_recv: recv,
negotiated,
host_caps,
}),
Err(e) => Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
}
}
@@ -1,139 +0,0 @@
//! Input task: embedder events → QUIC datagrams. Toward a host that advertised
//! HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
//! folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
//! datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
//! per-transition event would corrupt held pad state until the *next* change — a held trigger
//! stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
//! reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
//! interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
//! getting the legacy per-transition gamepad events.
use super::super::*;
use super::*;
pub(super) async fn run(
conn: quinn::Connection,
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
gamepad_snapshots: bool,
) {
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
// Per-pad wrapping seq that PERSISTS across a pad's remove/re-add on the same index (the
// snapshot itself is cleared to `None` on removal). A removal takes `seq[idx] + 1` so it
// supersedes every prior snapshot; the re-added pad's first snapshot takes the next value
// after that, so the host's seq gate accepts it instead of rejecting a restarted-at-0 seq.
let mut seq: [u8; MAX_PADS] = [0; MAX_PADS];
// Re-sends of a removal still owed on refresh ticks (the removal rides the lossy datagram
// plane; a single lost one would silently strand a ghost pad on the host — the exact bug
// the removal fixes). Mirrors the host's rumble stop burst: a few time-spread re-sends,
// each with a fresh (higher) seq, and canceled the moment the pad is driven again.
const REMOVE_RESENDS: u8 = 2;
let mut remove_owed: [u8; MAX_PADS] = [0; MAX_PADS];
// Per-pad declared controller kind ([`GamepadArrival`]) + its owed re-sends: the host needs
// the kind before the pad's first frame to build a matching virtual device (mixed types), so
// like the removal it rides the lossy plane with a small time-spread re-send burst.
const ARRIVAL_RESENDS: u8 = 2;
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
ev = input_rx.recv() => {
let Some(ev) = ev else { break };
let idx = ev.flags as usize;
if gamepad_snapshots
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
&& idx < MAX_PADS
{
// The pad is being driven — cancel any owed removal (a re-plug on this
// index; its fresh snapshot seq already supersedes the removal's).
remove_owed[idx] = 0;
let snap = pads[idx].get_or_insert(GamepadSnapshot {
pad: idx as u8,
..Default::default()
});
// Unknown axis ids don't send (the host's legacy fold drops them too).
if snap.fold(&ev) {
seq[idx] = seq[idx].wrapping_add(1);
snap.seq = seq[idx];
let _ = conn
.send_datagram(snap.to_event().encode().to_vec().into());
}
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadRemove && idx < MAX_PADS {
// Stop refreshing the pad and forward a seq-stamped removal (in the shared
// seq space) so the host tears its virtual device down and no reordered
// snapshot can resurrect it; arm the re-send burst against datagram loss.
// Drop any owed kind declaration too — a re-plug on this index sends its own.
pads[idx] = None;
arrival[idx] = None;
arrival_owed[idx] = 0;
seq[idx] = seq[idx].wrapping_add(1);
remove_owed[idx] = REMOVE_RESENDS;
let rem = crate::input::InputEvent {
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
..ev
};
let _ = conn.send_datagram(rem.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
// Remember the declared kind (`code`) and forward it, arming a re-send burst
// so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let _ = conn.send_datagram(ev.encode().to_vec().into());
continue;
}
let _ = conn.send_datagram(ev.encode().to_vec().into());
}
_ = refresh.tick() => {
for idx in 0..MAX_PADS {
// Re-send an owed kind declaration (independent of whether the pad has state
// yet — it may be idle-but-connected). Idempotent on the host.
if arrival_owed[idx] > 0 {
if let Some(kind) = arrival[idx] {
arrival_owed[idx] -= 1;
let arr = crate::input::InputEvent {
kind: InputKind::GamepadArrival,
_pad: [0; 3],
code: kind as u32,
x: 0,
y: 0,
flags: idx as u32,
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
} else {
arrival_owed[idx] = 0;
}
}
if let Some(snap) = pads[idx].as_mut() {
seq[idx] = seq[idx].wrapping_add(1);
snap.seq = seq[idx];
let _ = conn.send_datagram(snap.to_event().encode().to_vec().into());
} else if remove_owed[idx] > 0 {
// Idempotent removal re-send with a fresh seq (the host drops it as a
// no-op once the pad is already gone, but a re-plug's later snapshot
// still wins by seq).
remove_owed[idx] -= 1;
seq[idx] = seq[idx].wrapping_add(1);
let rem = crate::input::InputEvent {
kind: InputKind::GamepadRemove,
_pad: [0; 3],
code: 0,
x: 0,
y: 0,
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
};
let _ = conn.send_datagram(rem.encode().to_vec().into());
}
}
}
}
}
}
@@ -25,10 +25,6 @@ pub(crate) struct WorkerArgs {
pub(crate) launch: Option<String>, pub(crate) launch: Option<String>,
pub(crate) pin: Option<[u8; 32]>, pub(crate) pin: Option<[u8; 32]>,
pub(crate) identity: Option<(String, String)>, pub(crate) identity: Option<(String, String)>,
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
/// dial loop re-dials a silent host within it, so a host still resuming from Wake-on-LAN
/// is caught the moment its network comes back instead of failing on the first attempt.
pub(crate) connect_timeout: std::time::Duration,
pub(crate) frames: Arc<FrameChannel>, pub(crate) frames: Arc<FrameChannel>,
pub(crate) audio_tx: SyncSender<AudioPacket>, pub(crate) audio_tx: SyncSender<AudioPacket>,
pub(crate) rumble_tx: SyncSender<RumbleUpdate>, pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
+4 -115
View File
@@ -145,11 +145,6 @@ pub async fn run(
let Some(cmd) = cmd else { break }; // NativeClient dropped let Some(cmd) = cmd else { break }; // NativeClient dropped
match cmd { match cmd {
ClipCommand::Fetch { xfer_id, seq, file_index, mime } => { ClipCommand::Fetch { xfer_id, seq, file_index, mime } => {
// Prune finished fetches first: a completed/failed/timed-out fetch task
// drops its cancel receiver, so its sender reads closed. Without this
// the map grew by one dead sender per paste for the whole session (only
// an explicit Cancel ever removed entries).
fetch_cancels.retain(|_, tx| !tx.is_closed());
let (cancel_tx, cancel_rx) = oneshot::channel(); let (cancel_tx, cancel_rx) = oneshot::channel();
fetch_cancels.insert(xfer_id, cancel_tx); fetch_cancels.insert(xfer_id, cancel_tx);
let conn = conn.clone(); let conn = conn.clone();
@@ -158,39 +153,14 @@ pub async fn run(
tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx)); tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx));
} }
ClipCommand::Serve { req_id, bytes, last } => { ClipCommand::Serve { req_id, bytes, last } => {
// Gate on a genuinely parked fetch (the waiter registers before the serve_bufs.entry(req_id).or_default().extend_from_slice(&bytes);
// FetchRequest event is emitted, so a live serve always finds it):
// bytes served under a stale/unknown/cancelled req_id would otherwise
// pool here for the whole session with Ok returned for every chunk.
if !serve_waiters.lock().unwrap().contains_key(&req_id) {
serve_bufs.remove(&req_id);
} else {
let buf = serve_bufs.entry(req_id).or_default();
if buf.len().saturating_add(bytes.len()) > CLIP_FETCH_CAP {
// The requester bounds its read at CLIP_FETCH_CAP — anything
// larger is memory burned toward a guaranteed peer rejection.
// Fail the transfer NOW: peer reads UNAVAILABLE, embedder gets
// an Error instead of silent Ok-per-chunk.
serve_bufs.remove(&req_id);
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
let _ = tx.send(None);
}
let _ = events.try_send(ClipEventCore::Error {
id: req_id,
code: PunktfunkStatus::InvalidArg as i32,
});
} else {
buf.extend_from_slice(&bytes);
if last { if last {
let full = serve_bufs.remove(&req_id).unwrap_or_default(); let full = serve_bufs.remove(&req_id).unwrap_or_default();
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
{
let _ = tx.send(Some(full)); let _ = tx.send(Some(full));
} }
} }
} }
}
}
ClipCommand::Cancel { id } => { ClipCommand::Cancel { id } => {
// Route to whichever table owns the id (they are disjoint by the high bit). // Route to whichever table owns the id (they are disjoint by the high bit).
if let Some(tx) = fetch_cancels.remove(&id) { if let Some(tx) = fetch_cancels.remove(&id) {
@@ -254,27 +224,8 @@ async fn serve_inbound(
return; return;
} }
// Overall stall bound (§3.4) — the inbound mirror of `run_outbound_fetch`'s: an embedder match rx.await {
// that never answers must not hold the waiter, this task, and the accepted bi-stream open Ok(Some(bytes)) => {
// for the rest of the session (~100 unanswered pastes exhaust the connection's bidi-stream
// budget and every later host paste stalls). `send.stopped()` additionally wakes us the
// moment the peer gives up on its side of the fetch.
let answer = tokio::select! {
r = rx => r.ok().flatten(),
_ = send.stopped() => {
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
None
}
_ = tokio::time::sleep(std::time::Duration::from_secs(FETCH_STALL_SECS)) => {
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
None
}
};
// Idempotent — the answering/denying paths already removed it; the stall paths must.
waiters.lock().unwrap().remove(&req_id);
match answer {
Some(bytes) => {
if clipstream::write_fetch_hdr( if clipstream::write_fetch_hdr(
&mut send, &mut send,
&ClipFetchHdr { &ClipFetchHdr {
@@ -351,65 +302,3 @@ async fn run_outbound_fetch(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::quic::test_util::connect_pair;
use crate::quic::CLIP_FILE_INDEX_NONE;
/// A serve chunk that alone breaches the requester-side CLIP_FETCH_CAP must fail the
/// transfer immediately — Error to the embedder, UNAVAILABLE to the peer — instead of
/// accumulating Ok-per-chunk toward a guaranteed peer-side rejection. Also pins the
/// waiter-membership gate: the serve only accumulated because the fetch was parked.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_serve_fails_the_transfer_instead_of_buffering() {
let (_s, _c, host_conn, client_conn) = connect_pair().await;
let (ev_tx, ev_rx) = std::sync::mpsc::sync_channel(16);
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(run(client_conn, ev_tx, cmd_rx));
// Host pastes: it opens a fetch bi-stream toward the client.
let req = ClipFetch {
seq: 1,
file_index: CLIP_FILE_INDEX_NONE,
mime: "text/plain;charset=utf-8".into(),
};
let (_send, mut recv) = clipstream::open_fetch(&host_conn, &req).await.unwrap();
// The client core surfaces the FetchRequest (the waiter is parked now).
let (req_id, ev_rx) = tokio::task::spawn_blocking(move || {
match ev_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
{
ClipEventCore::FetchRequest { req_id, .. } => (req_id, ev_rx),
other => panic!("expected FetchRequest, got {other:?}"),
}
})
.await
.unwrap();
cmd_tx
.send(ClipCommand::Serve {
req_id,
bytes: vec![0u8; CLIP_FETCH_CAP + 1],
last: false,
})
.unwrap();
let ev = tokio::task::spawn_blocking(move || {
ev_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
})
.await
.unwrap();
match ev {
ClipEventCore::Error { id, .. } => assert_eq!(id, req_id),
other => panic!("expected Error, got {other:?}"),
}
// The peer's read side sees the transfer refused, not a hang.
let hdr = clipstream::read_fetch_hdr(&mut recv).await.unwrap();
assert_eq!(hdr.status, CLIP_FETCH_UNAVAILABLE);
}
}
+6 -111
View File
@@ -1,6 +1,5 @@
//! Session configuration and protocol/FEC parameters. //! Session configuration and protocol/FEC parameters.
use crate::crypto::SessionKey;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES}; use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
use zeroize::Zeroize; use zeroize::Zeroize;
@@ -356,11 +355,9 @@ pub struct Config {
/// hostile/corrupt headers; see [`Session`](crate::session::Session)). /// hostile/corrupt headers; see [`Session`](crate::session::Session)).
pub max_frame_bytes: usize, pub max_frame_bytes: usize,
pub encrypt: bool, pub encrypt: bool,
/// The negotiated session AEAD + its key, established during pairing/handshake — /// AES-128 session key established during pairing. MUST be unique per session when
/// AES-128-GCM for every peer by default, ChaCha20-Poly1305 when the client negotiated it
/// (soft-AES armv7 targets; see [`SessionKey`]). MUST be unique per session when
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]). /// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
pub key: SessionKey, pub key: [u8; 16],
/// Per-session nonce salt, established alongside `key` during pairing. MUST be /// Per-session nonce salt, established alongside `key` during pairing. MUST be
/// unique per (key, session). /// unique per (key, session).
pub salt: [u8; 4], pub salt: [u8; 4],
@@ -385,8 +382,7 @@ impl std::fmt::Debug for Config {
.field("shard_payload", &self.shard_payload) .field("shard_payload", &self.shard_payload)
.field("max_frame_bytes", &self.max_frame_bytes) .field("max_frame_bytes", &self.max_frame_bytes)
.field("encrypt", &self.encrypt) .field("encrypt", &self.encrypt)
// SessionKey's own Debug redacts the material but keeps the cipher choice visible. .field("key", &"<redacted>")
.field("key", &self.key)
.field("salt", &"<redacted>") .field("salt", &"<redacted>")
.field("loopback_drop_period", &self.loopback_drop_period) .field("loopback_drop_period", &self.loopback_drop_period)
.finish() .finish()
@@ -430,7 +426,7 @@ impl Config {
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)", "max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
)); ));
} }
if self.encrypt && self.key.is_zero() { if self.encrypt && self.key == [0u8; 16] {
return Err(PunktfunkError::InvalidArg( return Err(PunktfunkError::InvalidArg(
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)", "encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
)); ));
@@ -453,7 +449,7 @@ impl Config {
shard_payload: 1024, shard_payload: 1024,
max_frame_bytes: 64 * 1024 * 1024, max_frame_bytes: 64 * 1024 * 1024,
encrypt: false, encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]), key: [0u8; 16],
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
} }
@@ -469,12 +465,7 @@ mod tests {
let mut c = Config::p1_defaults(Role::Host); let mut c = Config::p1_defaults(Role::Host);
c.encrypt = true; // key is still all-zero c.encrypt = true; // key is still all-zero
assert!(c.validate().is_err()); assert!(c.validate().is_err());
c.key = SessionKey::Aes128Gcm([1u8; 16]); c.key = [1u8; 16];
assert!(c.validate().is_ok());
// The rejection follows whichever cipher variant is active.
c.key = SessionKey::ChaCha20Poly1305([0u8; 32]);
assert!(c.validate().is_err());
c.key = SessionKey::ChaCha20Poly1305([1u8; 32]);
assert!(c.validate().is_ok()); assert!(c.validate().is_ok());
} }
@@ -557,100 +548,4 @@ mod tests {
Some(SteamController) Some(SteamController)
); );
} }
#[test]
fn compositor_pref_wire_and_names() {
for p in [
CompositorPref::Auto,
CompositorPref::Kwin,
CompositorPref::Wlroots,
CompositorPref::Mutter,
CompositorPref::Gamescope,
] {
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
}
// Aliases + unknowns.
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
assert_eq!(
CompositorPref::from_name("sway"),
Some(CompositorPref::Wlroots)
);
assert_eq!(CompositorPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
}
#[test]
fn gamepad_pref_wire_and_names() {
for p in [
GamepadPref::Auto,
GamepadPref::Xbox360,
GamepadPref::DualSense,
GamepadPref::XboxOne,
GamepadPref::DualShock4,
GamepadPref::SteamController,
GamepadPref::SteamDeck,
GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro,
GamepadPref::SteamController2,
GamepadPref::SteamController2Puck,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
// that only know a prefix of the range).
for (v, p) in [
(0, GamepadPref::Auto),
(1, GamepadPref::Xbox360),
(2, GamepadPref::DualSense),
(3, GamepadPref::XboxOne),
(4, GamepadPref::DualShock4),
(5, GamepadPref::SteamController),
(6, GamepadPref::SteamDeck),
(7, GamepadPref::DualSenseEdge),
(8, GamepadPref::SwitchPro),
(9, GamepadPref::SteamController2),
(10, GamepadPref::SteamController2Puck),
] {
assert_eq!(p.to_u8(), v);
assert_eq!(GamepadPref::from_u8(v), p);
}
// The next unassigned byte degrades to Auto today; assigning it later must update this.
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
assert_eq!(
GamepadPref::from_name("edge"),
Some(GamepadPref::DualSenseEdge)
);
assert_eq!(
GamepadPref::from_name("Switch-Pro"),
Some(GamepadPref::SwitchPro)
);
assert_eq!(
GamepadPref::from_name("ibex"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("sc2"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("sc2puck"),
Some(GamepadPref::SteamController2Puck)
);
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)
);
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
assert_eq!(GamepadPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
}
} }
+34 -194
View File
@@ -1,10 +1,9 @@
//! Session sealing with the negotiated AEAD — AES-128-GCM (matching GameStream's video //! AES-128-GCM session sealing, matching GameStream's video crypto in P1.
//! crypto in P1) by default, ChaCha20-Poly1305 (RFC 8439) for clients without hardware AES.
//! //!
//! ## Nonce uniqueness (the AEAD safety requirement) //! ## Nonce uniqueness (the GCM safety requirement)
//! //!
//! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a //! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a
//! `(key, nonce)` pair is catastrophic under either AEAD, so two precautions apply: //! `(key, nonce)` pair under AES-GCM is catastrophic, so two precautions apply:
//! //!
//! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each //! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each
//! counts its sequence from 0. To stop the host's video stream and the client's input //! counts its sequence from 0. To stop the host's video stream and the client's input
@@ -18,96 +17,17 @@
//! The sequence number is also passed as AEAD associated data, so tampering with the //! The sequence number is also passed as AEAD associated data, so tampering with the
//! on-wire sequence is detected (the tag check fails) rather than silently shifting the //! on-wire sequence is detected (the tag check fails) rather than silently shifting the
//! nonce. Note: this layer does not provide anti-replay — see `Session`. //! nonce. Note: this layer does not provide anti-replay — see `Session`.
//!
//! ## Why two ciphers
//!
//! Both AEADs are full-strength; the choice (negotiated via `Welcome::cipher`) is purely a
//! performance one. On targets without hardware AES — the soft-AES armv7 clients (webOS TVs) —
//! GCM's fixsliced AES + software GHASH costs ~50100 cycles/byte and caps decrypt at
//! ~100 Mbps, while ChaCha20-Poly1305's ARX construction runs ~1017 cycles/byte in portable
//! software (design/chacha20-session-cipher.md). Same 96-bit nonce, 16-byte tag, and AAD
//! shape, so the entire nonce discipline above carries over verbatim.
use crate::config::Role; use crate::config::Role;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
use aes_gcm::{Aes128Gcm, Key, Nonce}; use aes_gcm::{Aes128Gcm, Key, Nonce};
use chacha20poly1305::ChaCha20Poly1305;
use zeroize::Zeroize;
/// 16-byte AEAD authentication tag appended by either session cipher. /// 16-byte AEAD authentication tag appended by GCM.
pub const TAG_LEN: usize = 16; pub const TAG_LEN: usize = 16;
// The wire (CRYPTO_OVERHEAD) and every in-place split assume both negotiated AEADs append
// exactly TAG_LEN bytes — a different-tag cipher can never slip in behind this constant.
const _: () = assert!(std::mem::size_of::<aes_gcm::Tag>() == TAG_LEN);
const _: () = assert!(std::mem::size_of::<chacha20poly1305::Tag>() == TAG_LEN);
/// The negotiated session AEAD together with its key material — merged so the invalid state
/// (a ChaCha cipher with an AES-sized key, or vice versa) is unrepresentable. AES-128-GCM is
/// the default every peer speaks; ChaCha20-Poly1305 is granted to clients that advertised
/// [`VIDEO_CAP_CHACHA20`](crate::quic::VIDEO_CAP_CHACHA20) (the soft-AES armv7 targets —
/// see the module docs). 256 bits for ChaCha is what RFC 8439 requires.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SessionKey {
Aes128Gcm([u8; 16]),
ChaCha20Poly1305([u8; 32]),
}
impl SessionKey {
/// Canonical lowercase cipher name for session-start logs.
pub fn cipher_name(&self) -> &'static str {
match self {
SessionKey::Aes128Gcm(_) => "aes-128-gcm",
SessionKey::ChaCha20Poly1305(_) => "chacha20-poly1305",
}
}
/// True when the key material is all zeros — the pairing-layer footgun `Config::validate`
/// rejects when encryption is on (see the nonce-uniqueness contract in the module docs).
pub fn is_zero(&self) -> bool {
match self {
SessionKey::Aes128Gcm(k) => k == &[0u8; 16],
SessionKey::ChaCha20Poly1305(k) => k == &[0u8; 32],
}
}
}
/// Key material never appears in logs, whichever variant is active — only the cipher choice
/// (`Config`'s hand-written `Debug` relies on this).
impl std::fmt::Debug for SessionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionKey::Aes128Gcm(_) => f.write_str("Aes128Gcm(<redacted>)"),
SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305(<redacted>)"),
}
}
}
/// Same zeroize-on-drop discipline the raw key array had (`Config`'s `Drop`).
impl Zeroize for SessionKey {
fn zeroize(&mut self) {
match self {
SessionKey::Aes128Gcm(k) => k.zeroize(),
SessionKey::ChaCha20Poly1305(k) => k.zeroize(),
}
}
}
/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto
/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a
/// two-arm match right next to the cipher work itself.
// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly
// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off
// slack for a pointer chase on every per-datagram seal/open.
#[allow(clippy::large_enum_variant)]
enum Cipher {
Aes128Gcm(Aes128Gcm),
ChaCha20Poly1305(ChaCha20Poly1305),
}
pub struct SessionCrypto { pub struct SessionCrypto {
cipher: Cipher, cipher: Aes128Gcm,
/// Salt for nonces we seal with (our direction). /// Salt for nonces we seal with (our direction).
send_salt: [u8; 4], send_salt: [u8; 4],
/// Salt for nonces we open with (the peer's direction). /// Salt for nonces we open with (the peer's direction).
@@ -115,18 +35,11 @@ pub struct SessionCrypto {
} }
impl SessionCrypto { impl SessionCrypto {
pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self { pub fn new(key: &[u8; 16], salt: [u8; 4], role: Role) -> Self {
let cipher = match key { let key = Key::<Aes128Gcm>::from_slice(key);
SessionKey::Aes128Gcm(k) => {
Cipher::Aes128Gcm(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(k)))
}
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
Key::<ChaCha20Poly1305>::from_slice(k),
)),
};
let own = direction(role); let own = direction(role);
SessionCrypto { SessionCrypto {
cipher, cipher: Aes128Gcm::new(key),
send_salt: dir_salt(salt, own), send_salt: dir_salt(salt, own),
recv_salt: dir_salt(salt, own ^ 1), recv_salt: dir_salt(salt, own ^ 1),
} }
@@ -136,15 +49,14 @@ impl SessionCrypto {
/// authenticated as associated data. /// authenticated as associated data.
pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> { pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
let nonce = nonce(self.send_salt, seq); let nonce = nonce(self.send_salt, seq);
let aad = seq.to_be_bytes(); self.cipher
let payload = Payload { .encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext, msg: plaintext,
aad: &aad, aad: &seq.to_be_bytes(),
}; },
match &self.cipher { )
Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
}
.map_err(|_| PunktfunkError::Crypto) .map_err(|_| PunktfunkError::Crypto)
} }
@@ -157,15 +69,9 @@ impl SessionCrypto {
let nonce = nonce(self.send_salt, seq); let nonce = nonce(self.send_salt, seq);
let split = buf.len() - TAG_LEN; let split = buf.len() - TAG_LEN;
let (plaintext, tag_slot) = buf.split_at_mut(split); let (plaintext, tag_slot) = buf.split_at_mut(split);
let aad = seq.to_be_bytes(); let tag = self
let tag = match &self.cipher { .cipher
Cipher::Aes128Gcm(c) => { .encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext)
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
}
Cipher::ChaCha20Poly1305(c) => {
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
}
}
.map_err(|_| PunktfunkError::Crypto)?; .map_err(|_| PunktfunkError::Crypto)?;
tag_slot.copy_from_slice(&tag); tag_slot.copy_from_slice(&tag);
Ok(()) Ok(())
@@ -174,21 +80,20 @@ impl SessionCrypto {
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data). /// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> { pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
let nonce = nonce(self.recv_salt, seq); let nonce = nonce(self.recv_salt, seq);
let aad = seq.to_be_bytes(); self.cipher
let payload = Payload { .decrypt(
Nonce::from_slice(&nonce),
Payload {
msg: ciphertext, msg: ciphertext,
aad: &aad, aad: &seq.to_be_bytes(),
}; },
match &self.cipher { )
Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
}
.map_err(|_| PunktfunkError::Crypto) .map_err(|_| PunktfunkError::Crypto)
} }
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and /// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length) /// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
/// — byte-identical to `open`, just written in place. Both AEADs verify the tag *before* /// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet /// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec` /// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
/// that `open`'s convenience API allocates for every datagram at line rate — the receive /// that `open`'s convenience API allocates for every datagram at line rate — the receive
@@ -200,21 +105,13 @@ impl SessionCrypto {
let nonce = nonce(self.recv_salt, seq); let nonce = nonce(self.recv_salt, seq);
let split = buf.len() - TAG_LEN; let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split); let (ciphertext, tag) = buf.split_at_mut(split);
let aad = seq.to_be_bytes(); self.cipher
match &self.cipher { .decrypt_in_place_detached(
Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce), Nonce::from_slice(&nonce),
&aad, &seq.to_be_bytes(),
ciphertext, ciphertext,
aes_gcm::Tag::from_slice(tag), aes_gcm::Tag::from_slice(tag),
), )
Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&aad,
ciphertext,
chacha20poly1305::Tag::from_slice(tag),
),
}
.map_err(|_| PunktfunkError::Crypto)?; .map_err(|_| PunktfunkError::Crypto)?;
Ok(split) Ok(split)
} }
@@ -248,13 +145,6 @@ pub fn random_key() -> [u8; 16] {
k k
} }
/// Generate a fresh random ChaCha20-Poly1305 session key (RFC 8439's 256-bit size).
pub fn random_key32() -> [u8; 32] {
let mut k = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut k);
k
}
/// Generate a fresh random per-session nonce salt. /// Generate a fresh random per-session nonce salt.
pub fn random_salt() -> [u8; 4] { pub fn random_salt() -> [u8; 4] {
let mut s = [0u8; 4]; let mut s = [0u8; 4];
@@ -266,17 +156,9 @@ pub fn random_salt() -> [u8; 4] {
mod tests { mod tests {
use super::*; use super::*;
/// One fresh key per negotiated cipher — every sealing test below must hold for both.
fn both_keys() -> [SessionKey; 2] {
[
SessionKey::Aes128Gcm(random_key()),
SessionKey::ChaCha20Poly1305(random_key32()),
]
}
#[test] #[test]
fn seal_open_roundtrip_cross_direction() { fn seal_open_roundtrip_cross_direction() {
for key in both_keys() { let key = random_key();
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
@@ -293,11 +175,10 @@ mod tests {
// open its own outbound packet → distinct nonce spaces per direction. // open its own outbound packet → distinct nonce spaces per direction.
assert!(host.open(42, &sealed).is_err()); assert!(host.open(42, &sealed).is_err());
} }
}
#[test] #[test]
fn directions_use_distinct_nonce_spaces() { fn directions_use_distinct_nonce_spaces() {
for key in both_keys() { let key = random_key();
let salt = [0u8; 4]; // even an all-zero base salt must separate the directions let salt = [0u8; 4]; // even an all-zero base salt must separate the directions
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
@@ -307,11 +188,10 @@ mod tests {
client.seal(0, b"abc").unwrap() client.seal(0, b"abc").unwrap()
); );
} }
}
#[test] #[test]
fn open_in_place_matches_open_and_rejects_tampering() { fn open_in_place_matches_open_and_rejects_tampering() {
for key in both_keys() { let key = random_key();
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
@@ -341,11 +221,10 @@ mod tests {
let mut runt = vec![0u8; TAG_LEN - 1]; let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err()); assert!(client.open_in_place(0, &mut runt).is_err());
} }
}
#[test] #[test]
fn seal_in_place_matches_seal_and_opens() { fn seal_in_place_matches_seal_and_opens() {
for key in both_keys() { let key = random_key();
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
@@ -366,43 +245,4 @@ mod tests {
assert_eq!(client.open(7, &buf).unwrap(), msg); assert_eq!(client.open(7, &buf).unwrap(), msg);
} }
} }
}
#[test]
fn ciphers_are_not_interchangeable() {
// A packet sealed under one AEAD must not open under the other — negotiation skew has
// to fail loudly (a tag mismatch), never decode garbage. The ChaCha key repeats the AES
// key bytes so even overlapping key material can't accidentally interoperate.
let salt = random_salt();
let aes = SessionKey::Aes128Gcm([7u8; 16]);
let chacha = SessionKey::ChaCha20Poly1305([7u8; 32]);
let sealed = SessionCrypto::new(&aes, salt, Role::Host)
.seal(1, b"cross-cipher")
.unwrap();
assert!(SessionCrypto::new(&chacha, salt, Role::Client)
.open(1, &sealed)
.is_err());
let sealed = SessionCrypto::new(&chacha, salt, Role::Host)
.seal(1, b"cross-cipher")
.unwrap();
assert!(SessionCrypto::new(&aes, salt, Role::Client)
.open(1, &sealed)
.is_err());
}
#[test]
fn session_key_zero_check_and_debug_redaction() {
assert!(SessionKey::Aes128Gcm([0u8; 16]).is_zero());
assert!(SessionKey::ChaCha20Poly1305([0u8; 32]).is_zero());
assert!(!SessionKey::Aes128Gcm([1u8; 16]).is_zero());
assert!(!SessionKey::ChaCha20Poly1305([1u8; 32]).is_zero());
// Key bytes must never reach a log, whichever variant — only the cipher choice.
for key in both_keys() {
let dbg = format!("{key:?}");
assert!(dbg.contains("<redacted>"), "{dbg}");
}
let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]);
k.zeroize();
assert!(k.is_zero());
}
} }
-19
View File
@@ -86,18 +86,8 @@ impl ErasureCoder for Gf8Coder {
// No FEC: every original must already be present. // No FEC: every original must already be present.
return collect_originals(received, data_count); return collect_originals(received, data_count);
} }
// Same (k, m)-keyed cache as `encode_into`: a fresh ReedSolomon per lossy block costs a
// full generator build (k×2k Gauss-Jordan + total×k×k multiply) on the real-time pump
// thread AND forfeits the instance's decode-matrix cache for stable loss patterns.
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
let cached =
matches!(&*guard, Some((ck, cm, _)) if *ck == data_count && *cm == recovery_count);
if !cached {
let rs = ReedSolomon::new(data_count, recovery_count) let rs = ReedSolomon::new(data_count, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
*guard = Some((data_count, recovery_count, rs));
}
let rs = &guard.as_ref().expect("cache populated above").2;
rs.reconstruct_data(received) rs.reconstruct_data(received)
.map_err(|_| FecError::Backend("gf8 reconstruct"))?; .map_err(|_| FecError::Backend("gf8 reconstruct"))?;
collect_originals(received, data_count) collect_originals(received, data_count)
@@ -126,17 +116,8 @@ impl ErasureCoder for Gf8Coder {
for &(j, bytes) in recovery { for &(j, bytes) in recovery {
received[data_count + j] = Some(bytes.to_vec()); received[data_count + j] = Some(bytes.to_vec());
} }
// Cache the codec by (k, m) exactly as `encode_into`/`reconstruct` do (see the note
// there) — this path runs per lossy block on the pump thread.
let mut guard = self.rs.lock().unwrap_or_else(|p| p.into_inner());
let cached =
matches!(&*guard, Some((ck, cm, _)) if *ck == data_count && *cm == recovery_count);
if !cached {
let rs = ReedSolomon::new(data_count, recovery_count) let rs = ReedSolomon::new(data_count, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
*guard = Some((data_count, recovery_count, rs));
}
let rs = &guard.as_ref().expect("cache populated above").2;
rs.reconstruct_data(&mut received) rs.reconstruct_data(&mut received)
.map_err(|_| FecError::Backend("gf8 reconstruct"))?; .map_err(|_| FecError::Backend("gf8 reconstruct"))?;
for (i, h) in have.iter().enumerate() { for (i, h) in have.iter().enumerate() {
+1 -20
View File
@@ -16,17 +16,6 @@
//! (recv → open → reorder → FEC recover → reassemble) state machines. //! (recv → open → reorder → FEC recover → reassemble) state machines.
//! - [`transport`] — pluggable packet I/O (in-process loopback for tests; UDP for real). //! - [`transport`] — pluggable packet I/O (in-process loopback for tests; UDP for real).
//! - [`abi`] — the `extern "C"` surface and `cbindgen`-generated `punktfunk_core.h`. //! - [`abi`] — the `extern "C"` surface and `cbindgen`-generated `punktfunk_core.h`.
//! - [`config`] / [`error`] / [`stats`] — session configuration, the shared error/status
//! vocabulary, and the counters snapshot.
//! - [`input`] — the wire input-event vocabulary (keyboard/mouse/touch, gamepad snapshots).
//! - [`reject`] — typed application-close rejection codes · [`reanchor`] — the post-loss
//! freeze-until-reanchor client gate · [`render_scale`] — the shared render-scale setting ·
//! [`audio`] — Opus PCM decode for C-ABI embedders · [`wol`] — Wake-on-LAN.
//! - `quic` (feature `quic`) — the punktfunk/1 control plane: handshake, typed control
//! messages, pairing (SPAKE2), the datagram plane codecs, and clock sync. With it come
//! `client` (the embeddable NativeClient worker), `abr` (the adaptive-bitrate
//! controller), and `clipboard` (the shared-clipboard transport task). `tls`
//! (feature `tls`) — the pinned-fingerprint certificate verifier.
//! //!
//! ## Threading contract //! ## Threading contract
//! //!
@@ -94,15 +83,7 @@ pub use stats::Stats;
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` + /// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control /// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged. /// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
/// v9: `PunktfunkFrame` grew `received_ns` — the reassembly-completion receipt stamp, so pub const ABI_VERSION: u32 = 8;
/// embedders stop stamping receipt at the hand-off pull (which folds the pre-decode queue wait
/// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
/// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
/// is unchanged.
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 10;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+4 -263
View File
@@ -39,58 +39,10 @@ pub struct Packetizer {
/// DATA shards before any block's parity — all blocks' parity must stay alive until the /// DATA shards before any block's parity — all blocks' parity must stay alive until the
/// frame's second emission pass. /// frame's second emission pass.
recovery: Vec<Vec<Vec<u8>>>, recovery: Vec<Vec<Vec<u8>>>,
/// The peer's per-block `data + recovery` acceptance ceiling, frozen from the **negotiated**
/// config exactly as the far side derives it in [`ReassemblerLimits::from_config`]. Adaptive
/// FEC moves `fec.fec_percent` live ([`set_fec_percent`](Self::set_fec_percent)) but the
/// receiver's ceiling is computed once at session construction and never re-derived, so parity
/// must be clamped against this or a raised percentage puts blocks over the far side's bound —
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
/// producing a frame the receiver must reject).
max_blocks: usize,
}
/// One in-progress **streamed** access unit (design/nvenc-subframe-slice-output.md Phase 2):
/// the caller feeds encoder chunks in as they exist ([`Packetizer::push_streamed`]) and every
/// completed `max_data_per_block × shard_payload` block leaves under SENTINEL headers
/// (`frame_bytes = 0`, `block_count = 0` — "not final yet") before the AU's total size is
/// known; [`Packetizer::finish_streamed`] seals the tail block with the real totals and
/// `FLAG_EOF`. Only ever sent to a peer that advertised
/// [`crate::quic::VIDEO_CAP_STREAMED_AU`].
pub struct StreamedAu {
frame_index: u32,
pts_ns: u64,
user_flags: u32,
/// Bytes not yet sealed into a block. Kept ≤ one block by `push_streamed` (it flushes only
/// while STRICTLY more than a block is buffered, so the final block always has ≥ 1 byte and
/// a sentinel block is never retroactively the frame's last).
pending: Vec<u8>,
/// Sentinel blocks already emitted.
blocks_out: u16,
/// Total AU bytes accumulated so far (`pending` included).
total_bytes: u64,
/// The frame's first packet (block 0, shard 0 — carries `FLAG_SOF`) has been emitted.
opened: bool,
}
impl StreamedAu {
/// The wire frame index this AU is sealed with (the caller's RFI bookkeeping domain).
pub fn frame_index(&self) -> u32 {
self.frame_index
}
} }
impl Packetizer { impl Packetizer {
pub fn new(config: &Config) -> Self { pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
let total_data_max = config
.max_frame_bytes
.div_ceil(config.shard_payload.max(1))
.max(1);
Packetizer { Packetizer {
next_frame_index: 0, next_frame_index: 0,
next_probe_index: 0, next_probe_index: 0,
@@ -100,10 +52,6 @@ impl Packetizer {
version: config.phase as u8, version: config.phase as u8,
tail: Vec::new(), tail: Vec::new(),
recovery: Vec::new(), recovery: Vec::new(),
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
max_blocks: total_data_max.div_ceil(max_data).max(1),
} }
} }
@@ -225,15 +173,6 @@ impl Packetizer {
}; };
// Per-block shard geometry (deterministic — recomputed in both passes). // Per-block shard geometry (deterministic — recomputed in both passes).
let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block; let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block;
// Parity for a `k`-shard block: the configured percentage, clamped so the block's wire
// total never exceeds what the peer will accept (see `max_total_shards`). The clamp only
// binds on blocks near `max_data_per_block`; smaller blocks keep the full adaptive range,
// so raising FEC still buys real protection wherever there is headroom. Bound as locals,
// not as a `&self` method: `emit_one` below would otherwise capture all of `self` and
// collide with the `&mut self.recovery[b]` parity borrow.
let (fec, max_total_shards) = (self.fec, self.max_total_shards);
let recovery_for =
move |k: usize| fec.recovery_for(k).min(max_total_shards.saturating_sub(k));
// One parity pool per block, reused across frames (steady-state zero-alloc). // One parity pool per block, reused across frames (steady-state zero-alloc).
if self.recovery.len() < block_count { if self.recovery.len() < block_count {
@@ -244,7 +183,7 @@ impl Packetizer {
let mut total_recovery = 0usize; let mut total_recovery = 0usize;
for b in 0..block_count { for b in 0..block_count {
let k = block_data_count(b); let k = block_data_count(b);
let m = recovery_for(k); let m = self.fec.recovery_for(k);
if k + m > u16::MAX as usize { if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16")); return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
} }
@@ -265,7 +204,7 @@ impl Packetizer {
block_index: b as u16, block_index: b as u16,
block_count: block_count as u16, block_count: block_count as u16,
data_shards: k as u16, data_shards: k as u16,
recovery_shards: recovery_for(k) as u16, recovery_shards: self.fec.recovery_for(k) as u16,
shard_index: shard_index as u16, shard_index: shard_index as u16,
shard_bytes: payload as u16, shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC, magic: PUNKTFUNK_MAGIC,
@@ -284,7 +223,7 @@ impl Packetizer {
// This block's data shards: references into `frame` (plus the staged tail). // This block's data shards: references into `frame` (plus the staged tail).
let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect(); let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect();
let recovery_count = recovery_for(k); let recovery_count = self.fec.recovery_for(k);
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?; coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
for (shard_index, body) in data_shards.iter().enumerate() { for (shard_index, body) in data_shards.iter().enumerate() {
@@ -303,7 +242,7 @@ impl Packetizer {
let mut parity_left = total_recovery; let mut parity_left = total_recovery;
for b in 0..block_count { for b in 0..block_count {
let k = block_data_count(b); let k = block_data_count(b);
let recovery_count = recovery_for(k); let recovery_count = self.fec.recovery_for(k);
for r in 0..recovery_count { for r in 0..recovery_count {
parity_left -= 1; parity_left -= 1;
let mut flags = FLAG_PIC; let mut flags = FLAG_PIC;
@@ -317,202 +256,4 @@ impl Packetizer {
self.next_seq = next_seq; self.next_seq = next_seq;
Ok(()) Ok(())
} }
/// Open a **streamed** access unit (see [`StreamedAu`]). `frame_index` follows the same
/// contract as [`packetize_each`](Self::packetize_each): `Some(i)` = the caller owns the
/// video numbering; `None` draws from the internal counter.
pub fn begin_streamed(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: Option<u32>,
) -> StreamedAu {
let frame_index = frame_index.unwrap_or_else(|| {
let i = self.next_frame_index;
self.next_frame_index = i.wrapping_add(1);
i
});
StreamedAu {
frame_index,
pts_ns,
user_flags,
pending: Vec::new(),
blocks_out: 0,
total_bytes: 0,
opened: false,
}
}
/// Feed one encoder chunk into a streamed AU, emitting every block that COMPLETES under
/// sentinel headers (`frame_bytes = 0`, `block_count = 0`, exactly `max_data_per_block`
/// data shards — the receiver's offset formula needs no total). Flushes only while
/// STRICTLY more than one block is buffered, so the frame's last block — whose header must
/// carry the real totals — is never emitted here. Packets reach `emit` in wire order (the
/// caller's nonce order), data then parity per block.
pub fn push_streamed(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
au.total_bytes += chunk.len() as u64;
au.pending.extend_from_slice(chunk);
let block_bytes = self.fec.max_data_per_block as usize * self.shard_payload;
while au.pending.len() > block_bytes {
// Room for this sentinel block AND the final block after it, within the peer's
// per-frame block ceiling and the u16 wire field.
if au.blocks_out as usize + 2 > self.max_blocks.min(u16::MAX as usize) {
return Err(PunktfunkError::Unsupported(
"streamed AU exceeds the negotiated max_frame_bytes",
));
}
let sof = !au.opened;
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
let fi = au.frame_index;
self.emit_streamed_block(
fi,
pts,
uf,
bi,
&au.pending[..block_bytes],
0,
0,
sof,
false,
coder,
&mut emit,
)?;
au.pending.drain(..block_bytes);
au.blocks_out += 1;
au.opened = true;
}
Ok(())
}
/// Close a streamed AU: seal the final block — its headers carry the REAL
/// `frame_bytes`/`block_count`, which retro-validate the whole frame at the receiver — with
/// `FLAG_EOF` on the last emitted packet. An empty AU degenerates to today's single
/// zero-padded-shard frame (`block_count = 1`, never a sentinel).
pub fn finish_streamed(
&mut self,
au: StreamedAu,
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let frame_bytes = u32::try_from(au.total_bytes)
.map_err(|_| PunktfunkError::Unsupported("streamed AU exceeds u32 bytes"))?;
let block_count = au.blocks_out + 1;
self.emit_streamed_block(
au.frame_index,
au.pts_ns,
au.user_flags,
au.blocks_out,
&au.pending,
frame_bytes,
block_count,
!au.opened,
true,
coder,
&mut emit,
)
}
/// Seal ONE streamed block (data shards + its parity, in wire order). Sentinel blocks pass
/// `frame_bytes = 0` / `block_count = 0`; the final block passes the real totals. `sof`
/// marks the frame's very first packet, `eof` its very last.
#[allow(clippy::too_many_arguments)]
fn emit_streamed_block(
&mut self,
frame_index: u32,
pts_ns: u64,
user_flags: u32,
block_index: u16,
bytes: &[u8],
frame_bytes: u32,
block_count: u16,
sof: bool,
eof: bool,
coder: &dyn ErasureCoder,
emit: &mut impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let payload = self.shard_payload;
if payload > u16::MAX as usize {
return Err(PunktfunkError::InvalidArg("shard_payload exceeds u16"));
}
// At least one (zero-padded) data shard even for an empty final block (empty AU).
let k = bytes.len().div_ceil(payload).max(1);
let m = self
.fec
.recovery_for(k)
.min(self.max_total_shards.saturating_sub(k));
if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
}
// Stage the one possibly-partial (or empty-frame) shard in the zero-padded scratch.
let full_shards = bytes.len() / payload;
self.tail.clear();
self.tail.resize(payload, 0);
let rem = bytes.len() % payload;
if rem > 0 {
self.tail[..rem].copy_from_slice(&bytes[full_shards * payload..]);
}
let tail = &self.tail;
let shard_at = |s: usize| -> &[u8] {
if s < full_shards {
&bytes[s * payload..(s + 1) * payload]
} else {
tail.as_slice()
}
};
let data_shards: Vec<&[u8]> = (0..k).map(shard_at).collect();
if self.recovery.is_empty() {
self.recovery.push(Vec::new());
}
coder.encode_into(&data_shards, m, &mut self.recovery[0])?;
let mut next_seq = self.next_seq;
let mut emit_one = |next_seq: &mut u32, shard_index: usize, body: &[u8], flags: u8| {
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
let hdr = PacketHeader {
pts_ns,
frame_index,
stream_seq: seq,
frame_bytes,
user_flags,
block_index,
block_count,
data_shards: k as u16,
recovery_shards: m as u16,
shard_index: shard_index as u16,
shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC,
version: self.version,
fec_scheme: coder.scheme() as u8,
flags,
};
emit(&hdr, body)
};
for (shard_index, body) in data_shards.iter().enumerate() {
let mut flags = FLAG_PIC;
if sof && shard_index == 0 {
flags |= FLAG_SOF;
}
if eof && m == 0 && shard_index + 1 == k {
flags |= FLAG_EOF;
}
emit_one(&mut next_seq, shard_index, body, flags)?;
}
for r in 0..m {
let mut flags = FLAG_PIC;
if eof && r + 1 == m {
flags |= FLAG_EOF;
}
let body: &[u8] = &self.recovery[0][r];
emit_one(&mut next_seq, k + r, body, flags)?;
}
self.next_seq = next_seq;
Ok(())
}
} }
+18 -177
View File
@@ -70,12 +70,7 @@ struct BlockState {
} }
struct FrameBuf { struct FrameBuf {
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
/// arrived yet — the frame can't complete before they do (and retro-validate).
frame_bytes: usize, frame_bytes: usize,
/// Block count; 0 = unknown (sentinel-opened, totals not yet pinned). A legacy-opened
/// frame always has ≥ 1 here, so 0 doubles as the "unpinned streamed" marker.
block_count: usize, block_count: usize,
pts_ns: u64, pts_ns: u64,
user_flags: u32, user_flags: u32,
@@ -111,19 +106,8 @@ pub struct ReassemblerLimits {
impl ReassemblerLimits { impl ReassemblerLimits {
pub fn from_config(c: &Config) -> Self { pub fn from_config(c: &Config) -> Self {
let max_data = c.fec.max_data_per_block as usize; let max_data = c.fec.max_data_per_block as usize;
// Size the ceiling from the whole range adaptive FEC may reach, NOT from the percentage
// negotiated at session start: the sender moves `fec_percent` live (`Packetizer::
// set_fec_percent`, clamped to ≤ 90) and the wire is self-describing, so it never
// renegotiates. Deriving this from the start value made every packet of a large block
// fail the `total > max_total_shards` check once FEC ramped up — the block never
// accumulated a shard, the frame aged out, and the resulting loss drove FEC *higher*,
// wedging large frames at 100% loss exactly when FEC was meant to rescue the link. A
// current sender also clamps its side (`Packetizer::recovery_for`); this keeps an
// already-deployed sender that doesn't from wedging a current receiver. Still a hard
// pre-allocation bound against hostile headers — just the sender's clamp, not a stale
// snapshot of it.
let max_total = let max_total =
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards()); (max_data + c.fec.recovery_for(max_data)).min(c.fec.scheme.max_total_shards());
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1); let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
ReassemblerLimits { ReassemblerLimits {
shard_bytes: c.shard_payload, shard_bytes: c.shard_payload,
@@ -282,49 +266,25 @@ impl Reassembler {
|| total == 0 || total == 0
|| total > lim.max_total_shards || total > lim.max_total_shards
|| shard_index >= total || shard_index >= total
|| block_count == 0
|| block_count > lim.max_blocks
|| hdr.block_index as usize >= block_count
|| frame_bytes > lim.max_frame_bytes || frame_bytes > lim.max_frame_bytes
{ {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
// Streamed-AU sentinel ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): `block_count == 0` — a // Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame
// value no legacy sender ever emits — marks a NON-FINAL block of an AU whose total size // into consecutive blocks of exactly `max_data_per_block` data shards with only the LAST
// doesn't exist yet (the host is still encoding its tail). The exact derived-geometry // block smaller, and stamps the exact `frame_bytes` in every header. That makes every
// check below can't run without a total, so a sentinel is bounded by the negotiated // data shard's final AU offset computable on arrival —
// limits instead — and by construction: full-K exactly (the offset formula needs it),
// never the last block the limits allow (the real final block must still fit after it),
// and no total to lie about. The frame-wide exact check runs retroactively the moment
// the final block's real totals arrive (the pinning arm below).
let sentinel = block_count == 0;
let block_idx = hdr.block_index as usize;
// For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
// later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
if sentinel {
if frame_bytes != 0
|| data_shards != lim.max_data_shards
|| block_idx + 1 >= lim.max_blocks
{
drop(stats);
return Ok(None);
}
} else {
if block_count > lim.max_blocks || block_idx >= block_count {
drop(stats);
return Ok(None);
}
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a
// frame into consecutive blocks of exactly `max_data_per_block` data shards with
// only the LAST block smaller, and stamps the exact `frame_bytes` in every
// non-sentinel header. That makes every data shard's final AU offset computable on
// arrival —
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes // offset = (block_index × max_data_per_block + shard_index) × shard_bytes
// — which is what lets shards land straight in the frame buffer below. Enforce the // — which is what lets shards land straight in the frame buffer below. Enforce the
// invariant so a header lying about its geometry is dropped instead of scribbling // invariant so a header lying about its geometry is dropped instead of scribbling into
// into another shard's range. // another shard's range.
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1); let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let block_idx = hdr.block_index as usize;
let expect_data_shards = if block_idx + 1 == expect_blocks { let expect_data_shards = if block_idx + 1 == expect_blocks {
total_data - (expect_blocks - 1) * lim.max_data_shards total_data - (expect_blocks - 1) * lim.max_data_shards
} else { } else {
@@ -334,7 +294,6 @@ impl Reassembler {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
}
let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes]; let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes];
// Route by index space: speed-test probe filler (FLAG_PROBE in user_flags) reassembles in // Route by index space: speed-test probe filler (FLAG_PROBE in user_flags) reassembles in
@@ -380,13 +339,8 @@ impl Reassembler {
} }
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later // First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at // packets must agree with its geometry.
// the limits' maximum — its real size doesn't exist yet. let buf_len = total_data * shard_bytes;
let buf_len = if sentinel {
total_data_max * shard_bytes
} else {
total_data * shard_bytes
};
let frame = match win.frames.entry(hdr.frame_index) { let frame = match win.frames.entry(hdr.frame_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => { std::collections::hash_map::Entry::Vacant(e) => {
@@ -409,66 +363,7 @@ impl Reassembler {
}) })
} }
}; };
if sentinel { if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
// Sentinel packets carry no totals to cross-check: while the frame is unpinned
// (`block_count == 0`) they match by construction, and once the totals are pinned —
// whichever packet arrived first, sentinel or final (reorder is normal) — a
// sentinel block must still be NON-final under them. Its full-K shape was already
// enforced by the firewall, which is exactly what the pinned geometry demands of
// every non-final block, and its shard offsets are within the pinned range by
// `block_idx + 1 < block_count`.
if frame.block_count != 0 && block_idx + 1 >= frame.block_count {
drop(stats);
return Ok(None);
}
} else if frame.block_count == 0 {
// A streamed frame meets its FINAL block's real totals: retro-validate every block
// the sentinels created against the exact geometry these totals derive (the same
// invariant the firewall enforces per-packet for legacy frames), then PIN them. A
// header that lies — totals under which an already-received sentinel block is
// out of range or not full-K — kills the WHOLE frame: its landed shards can't be
// trusted to be at the offsets this geometry means, so delivering any of it would
// hand the decoder spliced bytes.
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let final_k = total_data - (expect_blocks - 1) * lim.max_data_shards;
let lied = frame.blocks.iter().any(|(&bi, b)| {
let bi = bi as usize;
bi >= expect_blocks
|| (bi + 1 < expect_blocks && b.data_shards != lim.max_data_shards)
|| (bi + 1 == expect_blocks && b.data_shards != final_k)
});
if lied {
let mut f = win
.frames
.remove(&hdr.frame_index)
.expect("frame entry exists");
*in_flight_bytes -= f.buf.len();
// Remember the index (with its late-shard memory, exactly like an aged-out
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
// count the loss — the client's recovery request is the right outcome for a
// frame that was destroyed by a lying header.
win.completed.insert(
hdr.frame_index,
reconstructed_shards(&f.blocks, lim.max_data_shards),
);
for block in f.blocks.values_mut() {
for slot in block.recovery.iter_mut() {
if let Some(rb) = slot.take() {
if recovery_pool.len() < RECOVERY_POOL_MAX {
recovery_pool.push(rb);
}
}
}
}
if !is_probe {
StatsCounters::add(&stats.frames_dropped, 1);
}
drop(stats);
return Ok(None);
}
frame.frame_bytes = frame_bytes;
frame.block_count = block_count;
} else if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
@@ -476,7 +371,6 @@ impl Reassembler {
buf, buf,
blocks, blocks,
blocks_ok, blocks_ok,
block_count: frame_block_count,
.. ..
} = frame; } = frame;
@@ -497,14 +391,6 @@ impl Reassembler {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
// Defense-in-depth (2026-07 security review): the geometry invariants above guarantee
// every packet of a block agrees on K, so this can't fire today — but `have_data`
// indexing and the recovery-slot math below assume it, and an explicit check keeps a
// future firewall refactor from turning that assumption into an OOB panic.
if block.data_shards != data_shards {
drop(stats);
return Ok(None);
}
if block.done { if block.done {
// A data shard the parity reconstruct already restored (`!have_data`) was late, not // A data shard the parity reconstruct already restored (`!have_data`) was late, not
// lost — net it out of the `fec_recovered_shards` it was counted into (see the // lost — net it out of the `fec_recovered_shards` it was counted into (see the
@@ -588,12 +474,8 @@ impl Reassembler {
} }
} }
// Whole frame ready? Judged against the FRAME's pinned block count, not this packet's // Whole frame ready?
// header — a streamed frame can complete on a reordered sentinel packet (header says 0) if *blocks_ok == block_count {
// after the final block already pinned the real totals, and can never complete before
// they're pinned (`0` never equals a non-zero `blocks_ok`).
let block_count = *frame_block_count;
if block_count != 0 && *blocks_ok == block_count {
let mut done = win.frames.remove(&hdr.frame_index).unwrap(); let mut done = win.frames.remove(&hdr.frame_index).unwrap();
win.completed.insert( win.completed.insert(
hdr.frame_index, hdr.frame_index,
@@ -607,7 +489,6 @@ impl Reassembler {
pts_ns: done.pts_ns, pts_ns: done.pts_ns,
flags: done.user_flags, flags: done.user_flags,
complete: true, complete: true,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
})); }));
} }
Ok(None) Ok(None)
@@ -625,10 +506,6 @@ impl Reassembler {
// The dropped frames' buffers (and their parity bufs) go back to the allocator, not the // The dropped frames' buffers (and their parity bufs) go back to the allocator, not the
// pool — a flush is the rare path. The budget resets with them. // pool — a flush is the rare path. The budget resets with them.
self.in_flight_bytes = 0; self.in_flight_bytes = 0;
// An aged-out partial parked for delivery is from the discarded past too — without this
// it survives `flush_backlog` and gets handed up as the first "frame" after the
// jump-to-live, exactly the stale content the flush existed to discard.
self.pending_partial = None;
} }
} }
@@ -702,10 +579,7 @@ impl ReassemblyWindow {
// where shards are missing (the codec's block walk skips zero windows). // where shards are missing (the codec's block walk skips zero windows).
// Newest-wins if several age out in one prune. Still counted dropped below. // Newest-wins if several age out in one prune. Still counted dropped below.
if let Some(sink) = partial_sink.as_deref_mut() { if let Some(sink) = partial_sink.as_deref_mut() {
// `frame_bytes > 0` also excludes an UNPINNED streamed frame (its total is if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 {
// still the 0 sentinel value): truncating its max-sized buffer to 0 would
// deliver an empty "partial" — worse than the plain drop it gets instead.
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 && f.frame_bytes > 0 {
let mut buf = std::mem::take(&mut f.buf); let mut buf = std::mem::take(&mut f.buf);
buf.truncate(f.frame_bytes); buf.truncate(f.frame_bytes);
let newer = sink let newer = sink
@@ -718,7 +592,6 @@ impl ReassemblyWindow {
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.user_flags, flags: f.user_flags,
complete: false, complete: false,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
}); });
} }
} }
@@ -759,35 +632,3 @@ impl ReassemblyWindow {
} }
} }
} }
#[cfg(test)]
mod reset_tests {
use super::*;
/// `flush_backlog` discards the past wholesale — an aged-out partial parked for delivery is
/// part of that past and must not survive [`Reassembler::reset`] to be handed up as the
/// first "frame" after a jump-to-live.
#[test]
fn reset_drops_a_parked_partial() {
let mut r = Reassembler::new(ReassemblerLimits {
shard_bytes: 64,
max_data_shards: 8,
max_total_shards: 16,
max_blocks: 4,
max_frame_bytes: 4096,
});
r.pending_partial = Some(Frame {
data: vec![0u8; 64],
frame_index: 7,
pts_ns: 1,
flags: 0,
complete: false,
received_ns: 0,
});
r.reset();
assert!(
r.take_partial().is_none(),
"a pre-flush partial must not survive reset()"
);
}
}
+2 -463
View File
@@ -1,7 +1,6 @@
use super::reassemble::LOSS_WINDOW_NS; use super::reassemble::LOSS_WINDOW_NS;
use super::*; use super::*;
use crate::config::{Config, FecScheme}; use crate::config::{Config, FecScheme};
use crate::crypto::SessionKey;
use crate::fec::coder_for; use crate::fec::coder_for;
use crate::stats::StatsCounters; use crate::stats::StatsCounters;
use zerocopy::{FromBytes, IntoBytes}; use zerocopy::{FromBytes, IntoBytes};
@@ -183,7 +182,7 @@ fn explicit_frame_index_is_stamped_and_internal_counter_untouched() {
shard_payload: 16, shard_payload: 16,
max_frame_bytes: 4096, max_frame_bytes: 4096,
encrypt: false, encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]), key: [0u8; 16],
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
}; };
@@ -293,7 +292,7 @@ fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config {
shard_payload: 16, shard_payload: 16,
max_frame_bytes: 4096, max_frame_bytes: 4096,
encrypt: false, encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]), key: [0u8; 16],
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
} }
@@ -580,463 +579,3 @@ fn rejects_wrong_shard_bytes_and_oversized_frame() {
.is_none()); .is_none());
assert_eq!(stats.snapshot().packets_dropped, 1); assert_eq!(stats.snapshot().packets_dropped, 1);
} }
/// Adaptive FEC raises `fec_percent` mid-session while the receiver's per-block acceptance
/// ceiling is frozen at session construction and never renegotiated. A maximal block must
/// therefore still land: the sender clamps its parity to the ceiling
/// (`Packetizer::recovery_for`), and the receiver sizes that ceiling from the whole clamp range
/// rather than the start percentage. Regression guard for the wedge this caused — every packet
/// of a large block failing `total > max_total_shards`, so the frame never completed and the
/// resulting loss drove adaptive FEC higher still.
#[test]
fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
let cfg = e2e_config(FecScheme::Gf16, 10);
let coder = coder_for(FecScheme::Gf16);
let lim = ReassemblerLimits::from_config(&cfg);
let mut pk = Packetizer::new(&cfg);
// Ramp far past the negotiated 10% — exactly what `apply_fec_target` does under loss.
pk.set_fec_percent(50);
// A frame of full `max_data_per_block` blocks: where the ceiling actually binds.
let frame_len = cfg.shard_payload * cfg.fec.max_data_per_block as usize * 2;
let src: Vec<u8> = (0..frame_len).map(|i| (i * 131 + 7) as u8).collect();
let pkts = pk.packetize(&src, 1, 0, coder.as_ref()).unwrap();
let k = cfg.fec.max_data_per_block as usize;
let mut clamped = false;
for p in &pkts {
let hdr = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
let total = hdr.data_shards as usize + hdr.recovery_shards as usize;
assert!(
total <= lim.max_total_shards,
"block total {total} exceeds the peer's ceiling {} — every packet of this block \
would be dropped",
lim.max_total_shards
);
// The unclamped 50% would put 2 parity on a full block; the negotiated 10% ceiling
// leaves room for 1. Proves the clamp actually bound rather than passing vacuously.
if hdr.data_shards as usize == k {
assert!(
(hdr.recovery_shards as usize) < cfg.fec.recovery_for(k).max(1) + 1,
"parity must be clamped to the peer's ceiling"
);
clamped = true;
}
}
assert!(clamped, "test must exercise a maximal block");
// And the frame still reassembles byte-identically.
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let mut got = None;
for p in &pkts {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
got = Some(f);
}
}
assert_eq!(
got.expect("frame must complete after an adaptive-FEC ramp")
.data,
src
);
}
// ---------------------------------------------------------------------------
// Streamed access units (VIDEO_CAP_STREAMED_AU — nvenc-subframe-slice-output.md Phase 2)
// ---------------------------------------------------------------------------
/// Packetize one streamed AU from `chunks` via begin/push/finish, returning the emitted wire
/// packets (header ++ shard) and the concatenated source bytes.
fn streamed_packets(
scheme: FecScheme,
fec_percent: u8,
chunks: &[&[u8]],
) -> (Vec<Vec<u8>>, Vec<u8>) {
let cfg = e2e_config(scheme, fec_percent);
let coder = coder_for(scheme);
let mut pk = Packetizer::new(&cfg);
let mut au = pk.begin_streamed(12345, 0, Some(0));
let mut pkts: Vec<Vec<u8>> = Vec::new();
let mut src = Vec::new();
for c in chunks {
src.extend_from_slice(c);
pk.push_streamed(&mut au, c, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
}
pk.finish_streamed(au, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
(pkts, src)
}
/// Deliver a streamed AU's packets (optionally with kills within the FEC budget, reversed
/// order, and a duplicate) and assert byte-identical completion. Reversed order is the
/// critical case: the FINAL block's real-total headers arrive FIRST, the frame opens
/// legacy-shaped, and the sentinels must still be accepted against the pinned totals.
fn streamed_roundtrip(scheme: FecScheme, kill: &[usize], reverse: bool) {
let chunks: Vec<Vec<u8>> = (0..3)
.map(|c| (0..50).map(|i| (c * 57 + i * 131 + 7) as u8).collect())
.collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(scheme, 50, &chunk_refs);
// 150 B / 16 B shards / 4-shard blocks → sentinel blocks 0,1 (4 data + 2 rec each) +
// final block (2 data + 1 rec) = 15 packets.
assert_eq!(
pkts.len(),
15,
"expected geometry changed — update the kills"
);
let mut delivery: Vec<Vec<u8>> = pkts
.iter()
.enumerate()
.filter(|(i, _)| !kill.contains(i))
.map(|(_, p)| p.clone())
.collect();
if reverse {
delivery.reverse();
}
if let Some(dup) = delivery.first().cloned() {
delivery.push(dup);
}
let cfg = e2e_config(scheme, 50);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let coder = coder_for(scheme);
let stats = StatsCounters::default();
let mut got = None;
for p in &delivery {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
assert!(got.is_none(), "frame must complete exactly once");
got = Some(f);
}
}
let f = got.expect("streamed frame must complete within the FEC budget");
assert_eq!(
f.data, src,
"reassembled streamed AU must be byte-identical"
);
assert_eq!(f.pts_ns, 12345);
assert!(f.complete);
}
#[test]
fn streamed_roundtrip_clean_and_reversed() {
streamed_roundtrip(FecScheme::Gf16, &[], false);
streamed_roundtrip(FecScheme::Gf16, &[], true);
streamed_roundtrip(FecScheme::Gf8, &[], true);
}
/// Loss within each block's FEC budget: one data shard from a sentinel block, one from the
/// final block — in both delivery orders.
#[test]
fn streamed_roundtrip_survives_loss_and_reorder() {
// Wire order: blk0 = 0..4 data + 4..6 rec, blk1 = 6..10 data + 10..12 rec,
// final = 12..14 data + 14 rec.
streamed_roundtrip(FecScheme::Gf16, &[1, 12], false);
streamed_roundtrip(FecScheme::Gf16, &[1, 12], true);
}
/// The wire shape of a streamed AU: sentinel headers (block_count = 0, frame_bytes = 0,
/// full-K) on every non-final block, real totals + EOF on the final block, SOF on the very
/// first packet only.
#[test]
fn streamed_headers_sentinel_then_final() {
let chunks: Vec<Vec<u8>> = (0..3).map(|_| vec![0xA5u8; 50]).collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &chunk_refs);
let mut saw_final = false;
for (i, p) in pkts.iter().enumerate() {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.flags & FLAG_SOF != 0,
i == 0,
"SOF exactly on the first packet"
);
assert_eq!(
h.flags & FLAG_EOF != 0,
i + 1 == pkts.len(),
"EOF exactly on the last packet"
);
if h.block_index < 2 {
assert_eq!(
h.block_count, 0,
"non-final block must ride sentinel headers"
);
assert_eq!(h.frame_bytes, 0);
assert_eq!(h.data_shards, 4, "sentinel blocks are exactly full-K");
} else {
saw_final = true;
assert_eq!(h.block_count, 3, "final block carries the real block count");
assert_eq!(h.frame_bytes as usize, src.len(), "and the real AU size");
}
}
assert!(saw_final);
}
/// A streamed AU smaller than one block emits NO sentinels — its single (final) block is
/// byte-identical in shape to a legacy frame, so small frames pay zero streaming overhead
/// and any receiver accepts them.
#[test]
fn streamed_small_frame_degenerates_to_legacy() {
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &[&[0x5Au8; 40]]);
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.block_count, 1,
"single-block streamed AU must be legacy-shaped"
);
assert_eq!(h.frame_bytes as usize, src.len());
}
}
/// Sentinel firewall: a sentinel header that is not exactly full-K, or claims a non-zero
/// total, or sits where the final block could no longer follow, is dropped before any
/// allocation happens.
#[test]
fn streamed_sentinel_firewall_bounds() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel = |f: fn(&mut PacketHeader)| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8; // limits().max_data_shards — the only legal sentinel K
h.recovery_shards = 0;
f(&mut h);
h
};
// Not full-K.
let h = sentinel(|h| h.data_shards = 7);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Claims a total.
let h = sentinel(|h| h.frame_bytes = 64);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sits on the last block the limits allow (no room for the final block after it).
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, 3);
// A conformant sentinel IS accepted (proves the rejections above weren't vacuous).
let h = sentinel(|_| {});
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
3,
"conformant sentinel accepted"
);
}
/// Retro-validation: final-block totals under which an already-received sentinel block is
/// out of range (or mis-sized) kill the WHOLE frame — no spliced delivery — and the killed
/// index cannot be resurrected by stragglers.
#[test]
fn streamed_lying_final_totals_kill_the_frame_wholesale() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Two sentinel blocks (indexes 0 and 1) open the frame and land shards.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
// A "final" header claiming the whole AU is ONE 16-byte shard: geometry-valid on its own
// (expect_blocks = 1, K = 1), but it disowns both sentinel blocks → the frame dies.
let mut lying = base_header();
lying.block_count = 1;
lying.frame_bytes = 16;
lying.data_shards = 1;
lying.recovery_shards = 0;
assert!(r
.push(&packet(lying), coder.as_ref(), &stats)
.unwrap()
.is_none());
let snap = stats.snapshot();
assert_eq!(
snap.frames_dropped, 1,
"the lying frame must be counted lost"
);
// A straggler sentinel for the killed index must not resurrect it.
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"straggler for a killed frame must be dropped, not re-open it"
);
}
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
#[test]
fn streamed_open_amplification_is_budget_bounded() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
for fi in 0..5u32 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.frame_index = fi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth max-sized open must be refused by the in-flight budget"
);
}
/// The final-first order's single buffer-safety guard (2026-07 security review finding 3): a
/// frame opened by its FINAL block allocates an EXACT-sized buffer; a sentinel aimed at (or
/// past) the pinned final slot must be dropped — without the guard its full-K write would land
/// outside that buffer. And the reject must not corrupt the frame: it still completes.
#[test]
fn streamed_out_of_range_sentinel_after_final_first_is_dropped() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Final block opens the frame: block_count = 1, frame_bytes = 32 → K = 2. Send shard 0
// only, so the frame stays in flight with the totals pinned.
let mut fin = base_header();
fin.block_count = 1;
fin.frame_bytes = 32;
fin.data_shards = 2;
fin.recovery_shards = 0;
fin.shard_index = 0;
assert!(r
.push(&packet(fin), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sentinels at the final slot (0) and past it (1): both non-final-impossible under the
// pinned block_count = 1 → dropped, never written into the 32-byte buffer.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(stats.snapshot().packets_dropped, 2);
// The frame is unharmed: its real second shard completes it at the exact pinned length.
let mut fin2 = fin;
fin2.shard_index = 1;
let got = r
.push(&packet(fin2), coder.as_ref(), &stats)
.unwrap()
.expect("frame must still complete after the rejected sentinels");
assert_eq!(got.data.len(), 32);
assert!(got.complete);
}
/// A second "final" header with DIFFERENT totals must be rejected once a streamed frame is
/// pinned (re-pinning would re-interpret already-landed shards), and the frame must still
/// complete under the first totals.
#[test]
fn streamed_second_final_with_different_totals_is_rejected() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel_shard = |shard_index: u16| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = 0;
h.shard_index = shard_index;
h
};
let final_shard = |frame_bytes: u32, data_shards: u16, shard_index: u16| {
let mut h = base_header();
h.block_count = 2;
h.frame_bytes = frame_bytes;
h.data_shards = data_shards;
h.recovery_shards = 0;
h.block_index = 1;
h.shard_index = shard_index;
h
};
// Sentinel opens block 0, then the real final pins totals: 10 shards = 160 bytes.
assert!(r
.push(&packet(sentinel_shard(0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert!(r
.push(&packet(final_shard(160, 2, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
// A second final claiming 144 bytes (K = 1): geometry-valid alone, but it contradicts the
// pinned totals → dropped.
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(final_shard(144, 1, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, before + 1);
// The frame still completes under the FIRST totals: the rest of block 0 + the final tail.
let mut got = None;
for s in 1..8u16 {
assert!(got.is_none());
got = r
.push(&packet(sentinel_shard(s)), coder.as_ref(), &stats)
.unwrap();
}
assert!(got.is_none(), "block 1 still owes a shard");
let got = r
.push(&packet(final_shard(160, 2, 1)), coder.as_ref(), &stats)
.unwrap()
.expect("frame completes under the first pinned totals");
assert_eq!(got.data.len(), 160);
}
+1 -103
View File
@@ -32,29 +32,6 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window /// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
/// reassembler would silently drop as stale. /// reassembler would silently drop as stale.
pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
/// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
/// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
/// before the AU's total size exists — while the tail of the frame is still encoding — so the
/// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
/// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
/// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
/// formula needs no total); the FINAL block's headers carry the real
/// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
/// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
/// the fallback is zero-risk.
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
/// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
/// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
/// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
/// construction runs 47× faster in portable code (design/chacha20-session-cipher.md).
/// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
/// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
/// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
/// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
/// control channel, so there is no downgrade surface.
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] /// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder /// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
@@ -114,13 +91,8 @@ pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Opti
return None; return None;
} }
// Honor the client's preference when the host can also emit it; else fall back to precedence. // Honor the client's preference when the host can also emit it; else fall back to precedence.
// `preferred` is a single-bit field by contract but arrives as a raw wire byte — isolate ONE
// bit of the intersection instead of echoing the request, so a non-conformant multi-bit
// value can never escape as a codec id (downstream `from_wire` folds unknown values to HEVC,
// which may not even be in the shared set).
if preferred != 0 && shared & preferred != 0 { if preferred != 0 && shared & preferred != 0 {
let want = shared & preferred; return Some(preferred);
return Some(want & want.wrapping_neg());
} }
// Precedence: HEVC > AV1 > H.264. // Precedence: HEVC > AV1 > H.264.
[CODEC_HEVC, CODEC_AV1, CODEC_H264] [CODEC_HEVC, CODEC_AV1, CODEC_H264]
@@ -199,77 +171,3 @@ impl Default for ColorInfo {
Self::SDR_BT709 Self::SDR_BT709
} }
} }
#[cfg(test)]
mod tests {
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode};
use crate::quic::*;
#[test]
fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() {
// The new cap packs into the existing trailing host_caps byte with no layout change.
assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE);
let mut w = Welcome {
abi_version: 1,
udp_port: 1,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0,
max_data_per_block: 1024,
},
shard_payload: 1024,
encrypt: false,
key: [0; 16],
salt: [0; 4],
frames: 0,
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
bit_depth: 8,
color: ColorInfo::SDR_BT709,
chroma_format: CHROMA_IDC_420,
audio_channels: 2,
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
cipher: 0,
key_chacha: None,
};
let got = Welcome::decode(&w.encode()).unwrap();
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
assert_eq!(
got.host_caps & HOST_CAP_GAMEPAD_STATE,
HOST_CAP_GAMEPAD_STATE
);
// Clipboard-off host: the bit is clear, gamepad bit still set.
w.host_caps = HOST_CAP_GAMEPAD_STATE;
assert_eq!(
Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD,
0
);
}
#[test]
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
// must still be a single bit of the shared set, never the raw multi-bit echo (which
// folds to HEVC downstream and can select a codec the client can't decode).
assert_eq!(
resolve_codec(CODEC_H264, CODEC_H264 | CODEC_AV1, CODEC_H264 | CODEC_AV1),
Some(CODEC_H264)
);
// Several shared preferred bits: still exactly one bit, and one of the preferred ones.
let got = resolve_codec(
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
CODEC_AV1 | CODEC_HEVC,
)
.unwrap();
assert_eq!(got.count_ones(), 1);
assert_ne!(got & (CODEC_AV1 | CODEC_HEVC), 0);
}
}
@@ -115,134 +115,3 @@ pub async fn read_data(recv: &mut quinn::RecvStream, max_bytes: usize) -> std::i
.await .await
.map_err(std::io::Error::other) .map_err(std::io::Error::other)
} }
// In-process QUIC loopback: the real clipstream fetch transport, both success and cancel.
#[cfg(test)]
mod tests {
use crate::quic::clipstream;
use crate::quic::test_util::connect_pair;
use crate::quic::*;
#[tokio::test]
async fn fetch_text_transfers_then_cancel_resets() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji
let holder_payload = payload.clone();
// Holder = the host side: accept two fetch streams. Serve the first; cancel the second.
let holder = tokio::spawn(async move {
// Fetch #1 — serve the payload.
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1");
let kind = clipstream::read_stream_header(&mut recv)
.await
.expect("stream header #1");
assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH);
let req = clipstream::read_fetch(&mut recv)
.await
.expect("fetch req #1");
assert_eq!(req.seq, 1);
assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE);
assert_eq!(req.mime, "text/plain;charset=utf-8");
clipstream::write_fetch_hdr(
&mut send,
&ClipFetchHdr {
status: CLIP_FETCH_OK,
total_size: holder_payload.len() as u64,
},
)
.await
.expect("write hdr #1");
clipstream::write_data(&mut send, &holder_payload)
.await
.expect("write data #1");
// Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM.
let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2");
clipstream::read_stream_header(&mut recv2)
.await
.expect("stream header #2");
let _ = clipstream::read_fetch(&mut recv2)
.await
.expect("fetch req #2");
send2.reset(clipstream::cancelled_code()).unwrap();
host_conn // keep alive until the requester side is done
});
// Requester = the client side.
// #1: full lazy fetch of the text payload.
let req = ClipFetch {
seq: 1,
file_index: CLIP_FILE_INDEX_NONE,
mime: "text/plain;charset=utf-8".into(),
};
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req)
.await
.expect("open fetch #1");
let hdr = clipstream::read_fetch_hdr(&mut recv)
.await
.expect("read hdr #1");
assert_eq!(hdr.status, CLIP_FETCH_OK);
assert_eq!(hdr.total_size as usize, payload.len());
let got = clipstream::read_data(&mut recv, 8 << 20)
.await
.expect("read data #1");
assert_eq!(got, payload);
// #2: the holder resets the stream — the requester surfaces an error rather than hanging.
let req2 = ClipFetch {
seq: 2,
file_index: CLIP_FILE_INDEX_NONE,
mime: "text/plain;charset=utf-8".into(),
};
let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2)
.await
.expect("open fetch #2");
assert!(
clipstream::read_fetch_hdr(&mut recv2).await.is_err(),
"a cancelled fetch must surface as an error, not a hang"
);
let _host_conn = holder.await.unwrap();
}
#[tokio::test]
async fn read_data_enforces_size_cap() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below
let holder_payload = big.clone();
let holder = tokio::spawn(async move {
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept");
clipstream::read_stream_header(&mut recv).await.unwrap();
let _ = clipstream::read_fetch(&mut recv).await.unwrap();
clipstream::write_fetch_hdr(
&mut send,
&ClipFetchHdr {
status: CLIP_FETCH_OK,
total_size: holder_payload.len() as u64,
},
)
.await
.unwrap();
let _ = clipstream::write_data(&mut send, &holder_payload).await;
host_conn
});
let req = ClipFetch {
seq: 1,
file_index: CLIP_FILE_INDEX_NONE,
mime: "application/octet-stream".into(),
};
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap();
assert_eq!(
clipstream::read_fetch_hdr(&mut recv).await.unwrap().status,
CLIP_FETCH_OK
);
// Cap below the payload size ⇒ read_data errors instead of buffering unboundedly.
assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err());
let _host_conn = holder.await.unwrap();
}
}
+2 -114
View File
@@ -38,7 +38,7 @@ pub struct ClockSkew {
/// with, so the offset aligns a client receive instant to the host's capture clock. /// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync( pub async fn clock_sync(
send: &mut quinn::SendStream, send: &mut quinn::SendStream,
recv: &mut io::MsgReader, recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> { ) -> Option<ClockSkew> {
use std::time::Duration; use std::time::Duration;
const ROUNDS: usize = 8; const ROUNDS: usize = 8;
@@ -50,7 +50,7 @@ pub async fn clock_sync(
if io::write_msg(send, &probe).await.is_err() { if io::write_msg(send, &probe).await.is_err() {
break; break;
} }
let read = tokio::time::timeout(read_timeout, recv.read_msg()).await; let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
let echo = match read { let echo = match read {
Ok(Ok(b)) => match ClockEcho::decode(&b) { Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e, Ok(e) => e,
@@ -154,115 +154,3 @@ impl Default for ClockResync {
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool { pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000) batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
} }
#[cfg(test)]
mod tests {
use crate::quic::*;
#[test]
fn clock_offset_picks_min_rtt_and_recovers_offset() {
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
// present but must be ignored by the min-RTT selection.
const OFF: i64 = 1_000_000;
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
let t1 = 0u64;
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
let t3 = t2 + 50_000;
let t4 = (t3 as i64 - OFF + 200_000) as u64;
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
let n1 = 1_000_000u64;
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
let n3 = n2 + 50_000;
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
let (offset, rtt) =
clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
assert_eq!(offset, OFF);
assert_eq!(rtt, 400_000);
assert!(clock_offset_ns(&[]).is_none());
}
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
#[test]
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
const OFF: i64 = 1_000_000;
let echo_for = |t1: u64, one_way: u64| ClockEcho {
t1_ns: t1,
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
};
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
let mut rs = ClockResync::new();
// An unsolicited echo before any batch is ignored.
assert_eq!(
rs.on_echo(&echo_for(42, 100_000), 500_000),
ResyncStep::Idle
);
let mut probe = rs.begin(1_000_000);
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
assert_eq!(
rs.on_echo(&echo_for(42, 100_000), 500_000),
ResyncStep::Idle
);
for round in 0..ClockResync::ROUNDS {
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p;
}
ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3t2) excluded
}
ResyncStep::Idle => panic!("matched echo must advance the batch"),
}
}
// The batch is done: even a matching-t1 replay no longer advances anything.
assert_eq!(
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
ResyncStep::Idle
);
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
let old = rs.begin(2_000_000);
let fresh = rs.begin(3_000_000);
assert_eq!(
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle
);
assert!(matches!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_)
));
}
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
/// replace the offset — its queueing delay biases the estimate exactly when frames
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
/// reject every later batch over normal jitter.
#[test]
fn clock_resync_acceptance_guard() {
// Generous connect RTT (10 ms): accept up to 1.5×.
assert!(accept_resync(14_000_000, 10_000_000));
assert!(!accept_resync(16_000_000, 10_000_000));
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
assert!(accept_resync(1_900_000, 200_000));
assert!(!accept_resync(2_100_000, 200_000));
// Boundary: exactly at the bound is accepted.
assert!(accept_resync(2_000_000, 0));
assert!(accept_resync(15_000_000, 10_000_000));
}
}

Some files were not shown because too many files have changed in this diff Show More