Compare commits

..

1 Commits

Author SHA1 Message Date
enricobuehler dab40ed98e feat(client/windows): PyroWave decode + surface it in the GUI codec picker
The decoder was gated to Linux because, when it landed, the Windows client
still had its own in-process WinUI/D3D11 presenter and the PyroWave present
path there was an open question. That client has since been retired: Windows
now spawns the SAME Vulkan session presenter as Linux, and the decoder is
plain Vulkan compute on the presenter's device (no fds, no dmabuf, no D3D11
interop), so the question that gated it answered itself. pyrowave-sys already
builds on Windows too -- the Windows HOST encoder ships on it.

So this is a port by un-gating: every cfg(all(target_os = "linux", feature =
"pyrowave")) becomes any(linux, windows) -- decoder module, backend variant,
Decoder::new_pyrowave, the CODEC_PYROWAVE advertisement, the session pump's
opt-in/build/label arms, and the presenter's planar CSC pass. No new code.

Then offer it in the Windows GUI, which is what prompted this. It stays
preference-only (resolve_codec never auto-picks it) and a host or device that
can't do PyroWave just falls back down the ladder to HEVC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:10:42 +02:00
249 changed files with 5661 additions and 29496 deletions
+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" \
+2 -3
View File
@@ -104,9 +104,8 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}" Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# All three client binaries — the shell spawns punktfunk-session.exe (a package # Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's # for every stream. --no-default-features on ARM64 is a no-op for the shell.
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release) - name: Build (release)
shell: pwsh shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }} run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
Generated
+27 -30
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.17.1" version = "0.14.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2881,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",
@@ -2895,7 +2894,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2904,7 +2903,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2916,7 +2915,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2930,11 +2929,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.17.1" version = "0.14.0"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2962,14 +2961,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2984,7 +2983,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3014,7 +3013,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3026,7 +3025,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3222,7 +3221,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3238,7 +3237,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3254,7 +3253,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3269,7 +3268,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3288,7 +3287,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3319,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3364,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",
@@ -3403,7 +3400,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3417,7 +3414,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.17.1" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3440,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.17.1" version = "0.14.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.1" version = "0.14.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 -1282
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
@@ -13,8 +13,8 @@
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; }; DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; }; E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; }; E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; }; E2CAFE000000000000000001 /* PunktfunkShared in Frameworks */ = {isa = PBXBuildFile; productRef = E2CAFE000000000000000002 /* PunktfunkShared */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -504,7 +504,6 @@
MARKETING_VERSION = 0.9.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -541,7 +540,6 @@
MARKETING_VERSION = 0.9.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -721,7 +719,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.9.1; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -766,7 +764,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.9.1; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -863,15 +861,15 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit; productName = PunktfunkKit;
}; };
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
};
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = { DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */; package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions; productName = SwiftUINavigationTransitions;
}; };
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = AA000000000000000000000D /* Project object */; rootObject = AA000000000000000000000D /* Project object */;
@@ -55,11 +55,6 @@
value = "1" value = "1"
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable
key = "MTL_HUD_ENABLED"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill")
.font(.title2) .font(.title2)
.foregroundStyle(Color.brand) .foregroundStyle(.tint)
Spacer(minLength: 0) Spacer(minLength: 0)
Text(host.displayName) Text(host.displayName)
.font(.headline) .font(.headline)
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Punktfunk") Text("Punktfunk")
.font(.caption).bold() .font(.caption).bold()
.foregroundStyle(Color.brand) .foregroundStyle(.tint)
ForEach(hosts.prefix(4)) { host in ForEach(hosts.prefix(4)) { host in
Link(destination: connectURL(host)) { Link(destination: connectURL(host)) {
HStack { HStack {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill")
.foregroundStyle(Color.brand) .foregroundStyle(.tint)
Text(host.displayName) Text(host.displayName)
.font(.subheadline) .font(.subheadline)
.lineLimit(1) .lineLimit(1)
@@ -184,47 +184,3 @@ private struct EmptyHostView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
// MARK: - Previews (Xcode canvas)
//
// Select the PunktfunkWidgetsExtension scheme and open the canvas (). The widget
// `#Preview(as:widget:timeline:)` form feeds sample entries directly the App-Group store is
// never read, so the canvas works without a paired device or saved hosts. The small preview's
// second entry shows the empty state one timeline click away.
private let previewHosts: [StoredHost] = [
StoredHost(
name: "Studio", address: "192.168.1.20",
lastConnected: .now.addingTimeInterval(-40 * 60)),
StoredHost(
name: "Living Room", address: "192.168.1.30",
lastConnected: .now.addingTimeInterval(-26 * 3600)),
StoredHost(
name: "Workstation", address: "10.0.0.5",
lastConnected: .now.addingTimeInterval(-6 * 86400)),
]
#Preview("Small", as: .systemSmall) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
HostsEntry(date: .now, hosts: [])
}
#Preview("Medium", as: .systemMedium) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
#Preview("Lock Screen circular", as: .accessoryCircular) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
HostsWidget()
} timeline: {
HostsEntry(date: .now, hosts: previewHosts)
}
@@ -19,69 +19,43 @@ struct PunktfunkSessionLiveActivity: Widget {
LockScreenView(context: context) LockScreenView(context: context)
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in } dynamicIsland: { context in
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
// region one purposeful row session status + the live numbers on the left, the
// End action on the right. COMPACT shows the one number worth a glance: live
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
// everywhere `.tint` used to leak system blue.
DynamicIsland { DynamicIsland {
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 6) { Label {
Text(context.attributes.hostName).font(.caption).lineLimit(1)
} icon: {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill")
.font(.subheadline)
.foregroundStyle(Color.brand)
Text(context.attributes.hostName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
} }
.padding(.leading, 4) .foregroundStyle(.tint)
.padding(.top, 2)
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
ElapsedClock(startedAt: context.state.startedAt) Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.subheadline) .font(.caption).monospacedDigit()
.frame(maxWidth: 56)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: 64, alignment: .trailing)
.padding(.trailing, 4)
.padding(.top, 2)
} }
DynamicIslandExpandedRegion(.center) { DynamicIslandExpandedRegion(.center) {
if let title = context.attributes.launchTitle { if let title = context.attributes.launchTitle {
Text(title) Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} }
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
// The expanded island's height is there to be used: one info row (status VStack(spacing: 6) {
// leading, live numbers trailing the mode string stays on the Lock Text(context.state.modeLine)
// Screen), then the platform-conventional LARGE full-width action button. .font(.caption2).foregroundStyle(.secondary).lineLimit(1)
VStack(spacing: 10) { StageLine(state: context.state)
HStack { EndButton()
StatusLine(state: context.state)
Spacer(minLength: 8)
StatsLine(state: context.state, showMode: false)
} }
Spacer(minLength: 0) // any slack height goes here button hugs the bottom
EndButton(fullWidth: true)
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 4)
.padding(.top, 6)
} }
} compactLeading: { } compactLeading: {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill").foregroundStyle(.tint)
.foregroundStyle(Color.brand)
} compactTrailing: { } compactTrailing: {
CompactReadout(state: context.state) Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.monospacedDigit()
.frame(maxWidth: 44)
} minimal: { } minimal: {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill").foregroundStyle(.tint)
.foregroundStyle(Color.brand)
} }
.keylineTint(Color.brand)
} }
} }
} }
@@ -95,20 +69,21 @@ private struct LockScreenView: View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: "play.tv.fill") Image(systemName: "play.tv.fill")
.font(.title2) .font(.title2)
.foregroundStyle(Color.brand) .foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 3) {
HStack { HStack {
Text(context.attributes.hostName).font(.headline).lineLimit(1) Text(context.attributes.hostName).font(.headline).lineLimit(1)
Spacer() Spacer()
ElapsedClock(startedAt: context.state.startedAt) Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
.font(.subheadline) .font(.subheadline).monospacedDigit()
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let title = context.attributes.launchTitle { if let title = context.attributes.launchTitle {
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
} }
StatusLine(state: context.state) Text(context.state.modeLine)
StatsLine(state: context.state) .font(.caption2).foregroundStyle(.secondary).lineLimit(1)
StageLine(state: context.state)
} }
if context.state.stage == .background { if context.state.stage == .background {
EndButton() EndButton()
@@ -120,155 +95,41 @@ private struct LockScreenView: View {
// MARK: - Shared pieces // MARK: - Shared pieces
/// The ticking elapsed-session clock client-side via `timerInterval`, no per-second push. /// The stage badge + (while backgrounded) the auto-disconnect countdown.
private struct ElapsedClock: View { private struct StageLine: View {
let startedAt: Date
var body: some View {
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
.monospacedDigit()
.multilineTextAlignment(.trailing)
}
}
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
private struct StatusLine: View {
let state: PunktfunkSessionAttributes.ContentState
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
label
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
private var color: Color {
switch state.stage {
case .streaming: return .green
case .background: return .orange
case .reconnecting: return .yellow
case .ending: return .secondary
}
}
@ViewBuilder private var label: some View {
switch state.stage {
case .streaming:
Text("Streaming")
case .background:
if let deadline = state.backgroundDeadline {
HStack(spacing: 3) {
Text("Background · ends in")
Text(timerInterval: Date()...deadline, countsDown: true)
.monospacedDigit()
}
} else {
Text("Running in background")
}
case .reconnecting:
Text("Reconnecting…")
case .ending:
Text("Session ended")
}
}
}
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
/// mode (`showMode` false on the island, where the row shares space with the status line).
/// Anything unreported simply doesn't appear.
private struct StatsLine: View {
let state: PunktfunkSessionAttributes.ContentState
var showMode = true
var body: some View {
HStack(spacing: 8) {
if let ms = state.latencyMs {
stat("bolt.fill", "\(ms) ms")
}
if let mbps = state.mbps {
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
}
if showMode {
Text(state.modeLine)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
private func stat(_ icon: String, _ text: String) -> some View {
HStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 8, weight: .semibold))
Text(text)
.font(.caption2)
.monospacedDigit()
}
.foregroundStyle(.secondary)
}
}
/// The compact trailing readout the island's one glanceable number. Streaming: the live
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
private struct CompactReadout: View {
let state: PunktfunkSessionAttributes.ContentState let state: PunktfunkSessionAttributes.ContentState
var body: some View { var body: some View {
switch state.stage { switch state.stage {
case .streaming: case .streaming:
if let ms = state.latencyMs { EmptyView()
Text("\(ms) ms")
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(.green)
} else {
ElapsedClock(startedAt: state.startedAt)
.font(.caption2)
.frame(maxWidth: 48)
}
case .background: case .background:
if let deadline = state.backgroundDeadline { if let deadline = state.backgroundDeadline {
HStack(spacing: 3) {
Text("Keeps running for")
Text(timerInterval: Date()...deadline, countsDown: true) Text(timerInterval: Date()...deadline, countsDown: true)
.font(.caption2)
.monospacedDigit() .monospacedDigit()
.multilineTextAlignment(.trailing) }
.frame(maxWidth: 48) .font(.caption2)
.foregroundStyle(.orange) .foregroundStyle(.secondary)
} else { } else {
Image(systemName: "moon.fill").foregroundStyle(.orange) badge("Running in background", .orange)
} }
case .reconnecting: case .reconnecting:
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow) badge("Reconnecting…", .yellow)
case .ending: case .ending:
Image(systemName: "stop.fill").foregroundStyle(.secondary) badge("Session ended", .secondary)
} }
} }
private func badge(_ text: String, _ color: Color) -> some View {
Text(text).font(.caption2).foregroundStyle(color)
}
} }
/// End-stream button runs EndStreamIntent in the app process (LiveActivityIntent). /// End-stream button runs EndStreamIntent in the app process (LiveActivityIntent).
/// `fullWidth` is the expanded island's large bottom action (the platform convention there);
/// the compact form remains the Lock Screen banner's trailing button while backgrounded.
private struct EndButton: View { private struct EndButton: View {
var fullWidth = false
var body: some View { var body: some View {
if fullWidth {
Button(intent: EndStreamIntent()) {
Label("End Session", systemImage: "stop.fill")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
}
.tint(.red)
.buttonStyle(.bordered)
} else {
Button(intent: EndStreamIntent()) { Button(intent: EndStreamIntent()) {
Label("End", systemImage: "stop.fill") Label("End", systemImage: "stop.fill")
.font(.caption).bold() .font(.caption).bold()
@@ -277,80 +138,3 @@ private struct EndButton: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
} }
}
// MARK: - Previews (Xcode canvas)
//
// Select the PunktfunkWidgetsExtension scheme and open the canvas () the activity
// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real
// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)`
// the island states (canvas device must be a Dynamic Island phone for those). Each listed
// content state becomes a frame in the canvas timeline strip, so all four session stages are one
// click apart. Sample state lives here (fileprivate), never in PunktfunkShared.
extension PunktfunkSessionAttributes {
fileprivate static var preview: PunktfunkSessionAttributes {
PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II")
}
}
extension PunktfunkSessionAttributes.ContentState {
fileprivate static var streaming: Self {
.init(
stage: .streaming, startedAt: .now.addingTimeInterval(-754),
modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2)
}
fileprivate static var backgrounded: Self {
.init(
stage: .background, startedAt: .now.addingTimeInterval(-1975),
modeLine: "2752×2064 @120 · HEVC · HDR",
backgroundDeadline: .now.addingTimeInterval(9 * 60))
}
fileprivate static var reconnecting: Self {
.init(
stage: .reconnecting, startedAt: .now.addingTimeInterval(-754),
modeLine: "2752×2064 @120 · HEVC · HDR")
}
fileprivate static var ended: Self {
.init(
stage: .ending, startedAt: .now.addingTimeInterval(-3541),
modeLine: "2752×2064 @120 · HEVC · HDR")
}
}
#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
PunktfunkSessionAttributes.ContentState.backgrounded
PunktfunkSessionAttributes.ContentState.reconnecting
PunktfunkSessionAttributes.ContentState.ended
}
#Preview(
"Island expanded", as: .dynamicIsland(.expanded),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
PunktfunkSessionAttributes.ContentState.backgrounded
}
#Preview(
"Island compact", as: .dynamicIsland(.compact),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
}
#Preview(
"Island minimal", as: .dynamicIsland(.minimal),
using: PunktfunkSessionAttributes.preview
) {
PunktfunkSessionLiveActivity()
} contentStates: {
PunktfunkSessionAttributes.ContentState.streaming
}
@@ -89,11 +89,6 @@ struct ContentView: View {
/// the remote-as-pointer controls, so it must be seen at least once per session. /// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false @State private var showShortcutHint = false
#endif #endif
#if os(iOS)
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
/// the disc must LEAVE the hierarchy so nothing composites over the metal layer).
@State private var showTouchExit = false
#endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
@@ -198,9 +193,6 @@ struct ContentView: View {
#if os(macOS) || os(tvOS) #if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start showShortcutHint = true // the 6 s shortcut banner, per session start
#endif #endif
#if os(iOS)
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
#endif
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host). // plus the accent ring on the most recent host).
guard let host = model.activeHost else { break } guard let host = model.activeHost else { break }
@@ -500,19 +492,12 @@ struct ContentView: View {
} }
// The resize spinner rides over the (blurred) stream; suppressed under the trust // The resize spinner rides over the (blurred) stream; suppressed under the trust
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep // prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
// steering and the next click still reaches the stream. Mounted ONLY while a // steering and the next click still reaches the stream.
// resize is live: resident structure above the CAMetalLayer is what the stage-4
// direct-to-display hunt is eliminating composited presents reach glass a full
// refresh later. The enter/exit fade rides the call-site transition + the
// .animation(value: resizing) below (the view's internal `if active` fade can't
// run when the whole view unmounts).
.overlay { .overlay {
if pendingFingerprint == nil, model.resizing { if pendingFingerprint == nil {
ResizeIndicatorView(active: true) ResizeIndicatorView(active: model.resizing)
.transition(.opacity.combined(with: .scale(scale: 0.92)))
} }
} }
.animation(.easeInOut(duration: 0.22), value: model.resizing)
if let fp = pendingFingerprint { if let fp = pendingFingerprint {
TrustCardView( TrustCardView(
fingerprint: fp, fingerprint: fp,
@@ -579,21 +564,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() }
@@ -610,8 +587,7 @@ struct ContentView: View {
}, },
endToEndMeter: model.endToEnd, endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage, decodeMeter: model.decodeStage,
displayMeter: model.displayStage, displayMeter: model.displayStage
presentFloorMeter: model.presentFloor
) )
.overlay(alignment: placement.alignment) { .overlay(alignment: placement.alignment) {
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a // The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
@@ -660,27 +636,18 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on // Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// screen the overlay off, or the compact pill (which carries no button) // screen the overlay off, or the compact pill (which carries no button)
// keep a minimal touch exit in a corner. It rides a material disc (like the // keep a minimal always-reachable exit in a corner. It rides a material disc
// HUD) so the glyph stays legible over a bright frame. // (like the HUD) so the glyph stays legible over a bright frame this is the
// // sole touch disconnect path in those tiers.
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
// above the stream a glass one doubly so, its blur SAMPLES the video layer
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
// latency and blocking direct-to-display promotion. Off is the immersive/
// measurement tier; after the fade, touch-only exits are backgrounding the app
// or re-enabling the stats overlay. Compact keeps its disc permanently that
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if captureEnabled, if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
Button { model.disconnect() } label: { Button { model.disconnect() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
// Floating glass disc over the frame (26+, material fallback). // Sole touch exit in the off/compact tiers a floating glass disc
// interactive: the disc IS the tap target, so the glass reacts // over the frame (26+, material fallback). interactive: the disc
// to press. // IS the tap target, so the glass reacts to press.
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
// Match the hit region to the visible disc so every tap also // Match the hit region to the visible disc so every tap also
// triggers the interactive-glass press highlight. // triggers the interactive-glass press highlight.
@@ -689,12 +656,6 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(12) .padding(12)
.accessibilityLabel("Disconnect") .accessibilityLabel("Disconnect")
.transition(.opacity)
.task {
guard statsVerbosity == .off else { return }
try? await Task.sleep(for: .seconds(8))
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
}
} }
} }
#endif #endif
@@ -102,26 +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
/// 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
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off no floor)
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The floor-shaved values every HUD tier displays (raw floor, never below 0). Identical
/// to the raw values whenever no floor is measured.
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their /// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero. /// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
@Published var lostFrames = 0 @Published var lostFrames = 0
@@ -153,12 +133,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
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter()
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta). /// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
private var lastFramesDropped: UInt64 = 0 private var lastFramesDropped: UInt64 = 0
private var statsTimer: Timer? private var statsTimer: Timer?
@@ -498,8 +472,6 @@ final class SessionModel: ObservableObject {
endToEndValid = false endToEndValid = false
decodeValid = false decodeValid = false
displayValid = false displayValid = false
clientQueueValid = false
osFloorValid = false
lostFrames = 0 lostFrames = 0
lostPct = 0 lostPct = 0
mouseCaptured = false mouseCaptured = false
@@ -683,34 +655,15 @@ final class SessionModel: ObservableObject {
} else { } else {
self.displayValid = false self.displayValid = false
} }
if let f = self.presentFloor.drain() {
self.osFloorP50Ms = f.p50Ms
self.osFloorValid = true
} else {
self.osFloorValid = false
}
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
} else {
self.clientQueueValid = false
}
// Mirror the window to the unified log (see statsLog) one line per second, // 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)
// a presentsfps gap is the presenter dropping/serializing, an fps deficit is // a presentsfps gap is the presenter dropping/serializing, an fps deficit is
// upstream (host capture/encode or the network). // upstream (host capture/encode or the network).
if frames > 0 { if frames > 0 {
// The classic fields stay RAW (cross-session comparability with every log
// captured before the 2026-07 floor policy); the appended trio carries the
// measured OS present floor and the floor-shaved values the HUD displays.
let line = String( 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).
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,
@@ -718,11 +671,7 @@ final class SessionModel: ObservableObject {
self.hostNetworkValid ? self.hostNetworkP50Ms : -1, self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1, self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1, self.displayValid ? self.displayP50Ms : -1,
lost, lost)
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
self.clientQueueValid ? self.clientQueueP50Ms : -1)
statsLog.info("\(line, privacy: .public)") statsLog.info("\(line, privacy: .public)")
} }
} }
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
private var compactLine: String { private var compactLine: String {
var parts = ["\(model.fps) fps"] var parts = ["\(model.fps) fps"]
if model.endToEndValid { if model.endToEndValid {
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
} else if model.hostNetworkValid { } else if model.hostNetworkValid {
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms)) parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
} }
@@ -88,46 +86,24 @@ struct StreamHUDView: View {
} }
if model.endToEndValid { if model.endToEndValid {
// Stage-2: the end-to-end headline (captureon-glass, measured directly, skew- // Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// corrected) "(same-host clock)" when the host didn't answer the skew // corrected) "(same-host clock)" when the host didn't answer the skew handshake.
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
// latency; the detailed tier shows the excluded floor as its own line, and the
// stats log keeps the raw values.
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// The equation (detailed tier only): the stages tiling the headline interval // The equation (detailed tier only): the stages tiling the headline interval
// (per-window p50s they only approximately sum to the directly-measured // (per-window p50s they only approximately sum to the directly-measured
// total). With a host that reports per-AU timings (0xCF) the first term splits // total). With a host that reports per-AU timings (0xCF) the first term splits
// into host + network (phase 2); an old host keeps the combined term. The // into host + network (phase 2); an old host keeps the combined term.
// display term is floor-shaved like the headline, so the equation still sums.
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid { if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid { if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")") Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")") Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if model.osFloorValid {
// The excluded OS term, kept visible for honesty: display-pipeline
// minimum no client can pace under (~2 refresh intervals composited).
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// Client-queue wait (reassembly receipt decode pull, ABI v9 split): ~0 on
// a healthy stream and hidden as noise; shown from 2 ms a persistent value
// is a client-side standing backlog that pre-split builds displayed as
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
// bleed logs alongside when it acts on the same state.
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
} }
} else if model.hostNetworkValid { } 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
@@ -40,12 +40,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presentPriority) private var presentPriority = @AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
SettingsOptions.presentPriorityDefault
@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
@@ -293,19 +288,11 @@ struct GamepadSettingsView: View {
+ "hardware decode.", + "hardware decode.",
value: $enable444), value: $enable444),
choiceRow( choiceRow(
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize", id: "presenter", icon: "rectangle.stack", label: "Presenter",
detail: "Lowest latency shows each frame the moment the display can take it; " detail: "Stage 3 paces presents to the display — lowest display latency. "
+ "Smoothness buffers a few frames to even out network hiccups. Applies " + "Stage 2 shows each frame on arrival. Applies from the next session.",
+ "from the next session.", options: SettingsOptions.presenters, current: presenter
options: SettingsOptions.presentPriorities, current: presentPriority ) { presenter = $0 },
) { presentPriority = $0 },
choiceRow(
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
detail: "How many frames Smoothness holds — each adds about a refresh of "
+ "display latency and absorbs about a refresh of jitter. Only applies "
+ "when prioritizing smoothness.",
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
) { smoothBuffer = $0 },
choiceRow( choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -348,22 +335,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
@@ -8,10 +8,7 @@ import SwiftUI
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not /// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
/// private) so the screenshot harness can open SettingsView on a specific category. /// private) so the screenshot harness can open SettingsView on a specific category.
enum SettingsCategory: String, CaseIterable, Identifiable { enum SettingsCategory: String, CaseIterable, Identifiable {
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the case general, display, audio, controllers, advanced, about
// picture (resolution, quality, presentation, host output), Input = touch/keyboard/mouse.
// The old Advanced tab dissolved (its lone game-library toggle lives in General now).
case general, display, input, audio, controllers, about
var id: Self { self } var id: Self { self }
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
switch self { switch self {
case .general: return "General" case .general: return "General"
case .display: return "Display" case .display: return "Display"
case .input: return "Input"
case .audio: return "Audio" case .audio: return "Audio"
case .controllers: return "Controllers" case .controllers: return "Controllers"
case .advanced: return "Advanced"
case .about: return "About" case .about: return "About"
} }
} }
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
switch self { switch self {
case .general: return "gearshape" case .general: return "gearshape"
case .display: return "display" case .display: return "display"
case .input: return "keyboard"
case .audio: return "speaker.wave.2" case .audio: return "speaker.wave.2"
case .controllers: return "gamecontroller" case .controllers: return "gamecontroller"
case .advanced: return "slider.horizontal.3"
case .about: return "info.circle" case .about: return "info.circle"
} }
} }
@@ -37,31 +37,28 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] = static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced /// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and /// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden static var presenters: [(label: String, tag: String)] {
/// PUNKTFUNK_PRESENTER debug env lever. var options: [(label: String, tag: String)] = [
static let presentPriorities: [(label: String, tag: String)] = [ ("Stage 2", "stage2"),
("Lowest latency", "latency"), ("Stage 3", "stage3"),
("Smoothness", "smooth"),
] ]
static let presentPriorityDefault = "latency" #if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2 #endif
/// frames). The ms hints derive from the chosen refresh setting each buffered frame costs return options
/// about one refresh interval of display latency and absorbs about one interval of arrival
/// jitter.
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
let periodMs = 1000.0 / Double(max(24, refreshHz))
func hint(_ frames: Int) -> String {
String(format: "+%.0f ms", Double(frames) * periodMs)
} }
return [
("Automatic", 0), /// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS and
("1 frame (\(hint(1)))", 1), /// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
("2 frames (\(hint(2)))", 2), /// this so an untouched picker shows what actually runs.
("3 frames (\(hint(3)))", 3), static var presenterDefault: String {
] #if os(macOS)
"stage2"
#else
"stage3"
#endif
} }
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value. /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
@@ -1,15 +1,5 @@
// SettingsView's shared sections each setting's Section is defined exactly once here and // SettingsView's shared sections each setting's Section is defined exactly once here and
// composed by the per-platform bodies in SettingsView.swift. // composed by the per-platform bodies in SettingsView.swift.
//
// 2026-07 settings revamp: every field carries its explanation DIRECTLY under it in the same
// cell (the `described` helper in SettingsView+Support) the old per-section footer paragraphs
// collected several fields' explanations into one blob nobody could match back to its row.
// Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize),
// the description is DYNAMIC it explains the current choice. The only footers left are the
// one-line "Applies from the next session." form notes.
//
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
#if os(iOS) #if os(iOS)
import CoreHaptics import CoreHaptics
@@ -18,23 +8,18 @@ import PunktfunkKit
import SwiftUI import SwiftUI
extension SettingsView { extension SettingsView {
// MARK: - Display: Resolution // MARK: - Sections (shared)
// NOTE: the Section content is deliberately split into the small named builders below as one // NOTE: the Section content is deliberately split into the small named builders below as one
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's // inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
// type-checker budget ("unable to type-check this expression in reasonable time"), which // type-checker budget ("unable to type-check this expression in reasonable time"), which
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch). // failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var resolutionSection: some View { @ViewBuilder var streamModeSection: some View {
Section("Resolution") { Section {
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS)
// Match-window (design/midstream-resolution-resize.md D1): follow the session // Match-window (design/midstream-resolution-resize.md D1): follow the session
// window/scene, renegotiating the host mode on a resize. Off the explicit mode below. // window/scene, renegotiating the host mode on a resize. Off the explicit mode below.
described(matchWindow
? "The host resizes its output to follow this window — the picture stays "
+ "pixel-exact (1:1) through every resize."
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
Toggle("Match window", isOn: $matchWindow) Toggle("Match window", isOn: $matchWindow)
}
#endif #endif
#if os(iOS) #if os(iOS)
iosResolutionWheel iosResolutionWheel
@@ -47,19 +32,75 @@ extension SettingsView {
TextField("", value: $height, format: .number.grouping(.never)) TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden() .labelsHidden()
} }
described("The host drives a real virtual output at exactly this size and refresh — "
+ "true pixels, no scaling.") {
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never)) TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
}
LabeledContent("") { LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() } Button("Use this display's mode") { fillFromMainScreen() }
} }
#endif #endif
#if !os(tvOS)
renderScaleRow
bitrateRows
#endif
} header: {
Text("Stream mode")
} footer: {
Text(matchWindow
? "The stream follows this window — the host resizes its virtual output to match "
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
+ "\(Self.bitrateFooter)"
: "The host creates a virtual output at exactly this mode — native resolution, but "
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
} }
} }
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
// (match-window derives the base from the live window, not these fields).
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
Toggle("Invert scroll direction", isOn: $invertScroll)
} header: {
Text("Keyboard & mouse")
} footer: {
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#endif
#if os(iOS) #if os(iOS)
// MARK: - Display: Resolution (iOS wheel) // MARK: - Stream mode (iOS wheel)
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) the /// Touch-first: a rotating wheel of common resolutions (this device's own mode first) the
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the /// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
@@ -78,11 +119,6 @@ extension SettingsView {
.labelsHidden() .labelsHidden()
.pickerStyle(.wheel) .pickerStyle(.wheel)
.frame(maxHeight: 140) .frame(maxHeight: 140)
Text("The host drives a real output at exactly this mode — true pixels, no scaling.")
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 360, alignment: .leading) // match the described-row caption cap
} }
} }
@@ -174,69 +210,10 @@ extension SettingsView {
} }
#endif #endif
// MARK: - Display: Quality
@ViewBuilder var qualitySection: some View {
Section("Quality") {
#if !os(tvOS) #if !os(tvOS)
renderScaleRow
bitrateRows
#endif
described("A preference — the host falls back if it can't encode it.") {
Picker("Video codec", selection: $codec) {
ForEach(SettingsOptions.codecs, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
described("HDR10, when the host has HDR content and this display supports it. "
+ "HEVC only; otherwise the stream stays SDR.") {
Toggle("10-bit HDR", isOn: $hdrEnabled)
}
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
+ "bits are better spent at 4:2:0. HEVC only.") {
Toggle("Full chroma (4:4:4)", isOn: $enable444)
}
}
}
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
described(renderScaleDescription) {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
}
}
/// Render scale explained, with the CONCRETE host resolution when it applies the cost made
/// legible. Only the explicit mode can show it (match-window derives the base from the live
/// window, not these fields).
private var renderScaleDescription: String {
var text = "Above native supersamples for sharpness; below renders lighter on the host "
+ "and the link."
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
+ "it to your display."
}
return text
}
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows. /// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View { @ViewBuilder private var bitrateRows: some View {
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
+ "fixed rate — a host card's context menu has a network speed test.") {
Toggle("Automatic bitrate", isOn: automaticBitrate) Toggle("Automatic bitrate", isOn: automaticBitrate)
}
if bitrateKbps != 0 { if bitrateKbps != 0 {
HStack(spacing: 12) { HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) { Slider(value: bitrateSlider, in: 0...1) {
@@ -256,225 +233,14 @@ extension SettingsView {
} }
#endif #endif
// MARK: - Display: Presentation
// The presentation intent (design/apple-presentation-rebuild.md replaced the visible
// stage picker): latency (newest-wins, zero queue) vs smoothness (a small deliberate jitter
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
@ViewBuilder var presentationSection: some View {
Section("Presentation") {
described(presentPriority == "smooth"
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
+ "added display latency."
: "Every frame shows the moment the display can take it — a network hiccup is "
+ "an occasional repeated or skipped frame.") {
Picker("Prioritize", selection: $presentPriority) {
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
Text(option.tag == SettingsOptions.presentPriorityDefault
? "\(option.label) (default)" : option.label)
.tag(option.tag)
}
}
}
if presentPriority == "smooth" {
described("Frames held back — each absorbs about one refresh of jitter and "
+ "adds one refresh of delay.") {
Picker("Buffer", selection: $smoothBuffer) {
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
#if !os(tvOS)
described("A ProMotion or adaptive-sync display follows the stream's rate — "
+ "smoother motion. No effect on fixed-refresh displays.") {
Toggle("Allow VRR", isOn: $allowVRR)
}
#endif
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
// only exists on the Mac (the layer's own sync stays off see MetalVideoPresenter).
#if os(macOS)
described("Flips align to the display's refresh — even pacing, up to one refresh "
+ "of added latency. Off shows frames as soon as they're ready.") {
Toggle("V-Sync", isOn: $vsync)
}
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
// affected setups, so the caption says so in plain words.
described(windowedSafePresent
? "Windowed streams present in step with the system compositor — avoids a macOS "
+ "display-driver crash seen on high-refresh displays, at a small latency "
+ "cost. Fullscreen always uses the fastest path."
: "Windowed streams use the fastest present path. On some high-refresh setups "
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
+ "restarts during windowed streaming.") {
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
}
#endif
}
}
// MARK: - Display: Host output
@ViewBuilder var hostOutputSection: some View {
Section {
described("The backend the host uses for its virtual output. A specific choice "
+ "falls back to auto-detection when that backend isn't available.") {
Picker("Compositor", selection: $compositor) {
ForEach(SettingsOptions.compositors, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
} header: {
Text("Host output")
} footer: {
// The one form-level note (deliberately not repeated on every row above).
Text("Display changes apply from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
// MARK: - General: Session
@ViewBuilder var sessionSection: some View {
Section("Session") {
#if os(macOS)
described("Go fullscreen when a session starts; return to a window on the host "
+ "list.") {
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
}
#endif
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
+ "aren't.") {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
}
#if os(iOS)
described("Audio and the connection stay live after you switch away; video pauses "
+ "to save power and resumes instantly when you return. Off, backgrounding "
+ "freezes the session.") {
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
}
if backgroundKeepAlive {
described("Ends a backgrounded session so it can't run down the battery.") {
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
Text("1 minute").tag(1)
Text("5 minutes").tag(5)
Text("10 minutes").tag(10)
Text("30 minutes").tag(30)
}
}
}
#endif
}
}
// MARK: - General: Statistics overlay
@ViewBuilder var overlaySection: some View {
Section("Statistics") {
described(Self.statisticsDescription) {
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
}
Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue)
}
}
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
}
}
// MARK: - General: Library
@ViewBuilder var librarySection: some View {
Section("Library") {
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
+ "games and launch one directly. No extra host setup.") {
Toggle("Show game library", isOn: $libraryEnabled)
}
}
}
// MARK: - Input
#if os(iOS)
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
@ViewBuilder var pointerSection: some View {
Section("Touch & pointer") {
described(touchModeDescription) {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
}
if UIDevice.current.userInterfaceIdiom == .pad {
described("Locks a hardware mouse for relative mouse-look in games; off sends "
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
}
}
}
/// The SELECTED touch mode explained dynamic, so the caption always describes what the
/// picker currently does instead of narrating all three modes at once.
private var touchModeDescription: String {
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
case .trackpad:
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
case .pointer:
return "The host cursor jumps to wherever you touch — tap is a click at that spot."
case .touch:
return "Real multi-touch reaches the host — for touch-native apps and games."
}
}
#endif
#if !os(tvOS)
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section("Keyboard & mouse") {
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
}
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
Toggle("Invert scroll direction", isOn: $invertScroll)
}
}
}
#endif
// MARK: - Audio
@ViewBuilder var audioSection: some View { @ViewBuilder var audioSection: some View {
Section { Section {
described("The speaker layout requested from the host.") {
Picker("Audio channels", selection: $audioChannels) { Picker("Audio channels", selection: $audioChannels) {
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
}
#if os(macOS) #if os(macOS)
described("Host audio plays through this device; System default follows your "
+ "Mac's output changes.") {
Picker("Speaker", selection: $speakerUID) { Picker("Speaker", selection: $speakerUID) {
Text("System default").tag("") Text("System default").tag("")
ForEach(outputDevices) { device in ForEach(outputDevices) { device in
@@ -485,11 +251,8 @@ extension SettingsView {
Text("Unavailable device").tag(speakerUID) Text("Unavailable device").tag(speakerUID)
} }
} }
}
#endif #endif
described("This device's microphone feeds the host's virtual mic.") {
Toggle("Send microphone to the host", isOn: $micEnabled) Toggle("Send microphone to the host", isOn: $micEnabled)
}
#if os(macOS) #if os(macOS)
Picker("Microphone", selection: $micUID) { Picker("Microphone", selection: $micUID) {
Text("System default").tag("") Text("System default").tag("")
@@ -505,7 +268,6 @@ extension SettingsView {
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user // Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
// pick it. Auto sums every channel (a lone hot mic still passes at full level). // pick it. Auto sums every channel (a lone hot mic still passes at full level).
if micChannelCount > 1 { if micChannelCount > 1 {
described("Pick the input your mic is on; Auto sums every channel.") {
Picker("Microphone channel", selection: $micChannel) { Picker("Microphone channel", selection: $micChannel) {
Text("Auto (all channels)").tag(0) Text("Auto (all channels)").tag(0)
ForEach(1...micChannelCount, id: \.self) { ch in ForEach(1...micChannelCount, id: \.self) { ch in
@@ -514,18 +276,257 @@ extension SettingsView {
} }
.disabled(!micEnabled) .disabled(!micEnabled)
} }
}
#endif #endif
} header: { } header: {
Text("Audio") Text("Audio")
} footer: { } footer: {
Text("Applies from the next session.") Text(Self.audioFooter)
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
// MARK: - Controllers #if os(iOS)
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
@ViewBuilder var pointerSection: some View {
Section {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
if UIDevice.current.userInterfaceIdiom == .pad {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: {
Text("Touch & pointer")
} footer: {
Text(pointerFooterText)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
/// `+` chain (with a ternary) inside the ViewBuilder that single expression blew Swift's
/// type-checker budget and was what actually broke the iOS archive.
private var pointerFooterText: String {
var text = "Trackpad: your finger moves the host cursor like a laptop touchpad — tap "
text += "to click, two-finger tap to right-click, two-finger drag to scroll, "
text += "tap-and-drag to hold, three-finger tap for the stats overlay. Direct pointer: "
text += "the cursor jumps to your finger. Touch passthrough: real multi-touch reaches "
text += "the host. Applies from the next touch."
if UIDevice.current.userInterfaceIdiom == .pad {
text += " Pointer capture locks a hardware mouse for relative mouse-look; off sends "
text += "absolute positions. Needs the stream full-screen and frontmost."
}
return text
}
#endif
@ViewBuilder var compositorSection: some View {
Section {
Picker("Compositor", selection: $compositor) {
ForEach(SettingsOptions.compositors, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
} header: {
Text("Host compositor")
} footer: {
Text("Which compositor drives the virtual output on the host. A specific "
+ "choice is honored only if that backend is available there — "
+ "otherwise the host falls back to auto-detection.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Auto-wake on connect fire Wake-on-LAN + wait for a sleeping saved host to come back before
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
@ViewBuilder var wakeSection: some View {
Section {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
} header: {
Text("Wake-on-LAN")
} footer: {
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
+ "way.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var windowSection: some View {
#if os(macOS)
Section {
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
} header: {
Text("Window")
} footer: {
Text("Take the window fullscreen when a session starts and restore it on the host "
+ "list, so only the stream is fullscreen — not the picker.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow.
@ViewBuilder var vrrSection: some View {
#if !os(tvOS)
Section {
Toggle("Allow VRR", isOn: $allowVRR)
} header: {
Text("Variable refresh rate")
} footer: {
Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the "
+ "stream — smoother motion without tearing. No effect on fixed-refresh displays. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only
// exists on the Mac (where the layer's own sync must stay off see MetalVideoPresenter).
@ViewBuilder var vsyncSection: some View {
#if os(macOS)
Section {
Toggle("V-Sync", isOn: $vsync)
} header: {
Text("Presentation")
} footer: {
Text("Off (default): each frame is shown as soon as it's ready — lowest latency, "
+ "but frame timing can look uneven and fullscreen may tear. On: frames flip "
+ "in step with the display's refresh — evenly paced, up to one refresh of "
+ "added latency. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
// pipeline with glass-gated present pacing). The default is per-platform glass on iOS/tvOS,
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
// the queue-saturation rationale) so the "(default)" marker rides presenterDefault instead
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
// DEBUG-only diagnostic it freezes hard on a lost HEVC reference.
@ViewBuilder var presenterSection: some View {
Section {
Picker("Presenter", selection: $presenter) {
ForEach(SettingsOptions.presenters, id: \.tag) { option in
Text(option.tag == SettingsOptions.presenterDefault
? "\(option.label) (default)" : option.label)
.tag(option.tag)
}
}
} header: {
Text("Video presenter")
} footer: {
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
+ "running near the stream's frame rate, queued frames can add two to three "
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var hdrSection: some View {
Section {
Picker("Video codec", selection: $codec) {
ForEach(SettingsOptions.codecs, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
Toggle("10-bit HDR", isOn: $hdrEnabled)
Toggle("Full chroma (4:4:4)", isOn: $enable444)
} header: {
Text("Video quality")
} footer: {
Text("Codec is a preference; the host falls back if it can't encode your choice. "
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
+ "both this device and the host support it — otherwise the stream stays 8-bit "
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
+ "session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var statisticsSection: some View {
Section {
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue)
}
}
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
} header: {
Text("Statistics")
} footer: {
Text(Self.statisticsFooter)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere
/// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general`
/// detail can reference it unconditionally.
@ViewBuilder var keepAliveSection: some View {
#if os(iOS)
Section {
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
if backgroundKeepAlive {
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
Text("1 minute").tag(1)
Text("5 minutes").tag(5)
Text("10 minutes").tag(10)
Text("30 minutes").tag(30)
}
}
} header: {
Text("Background")
} footer: {
Text("Off by default: backgrounding the app freezes the session. When on, audio keeps "
+ "playing and the connection stays live (video is dropped to save power) after you "
+ "switch away — and the session auto-disconnects after the time above so it can't "
+ "run down your battery. Returning to the app resumes video instantly.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
#endif
}
@ViewBuilder var experimentalSection: some View {
Section {
Toggle("Show game library", isOn: $libraryEnabled)
} header: {
Text("Experimental")
} footer: {
Text("Adds a “Browse Library…” action to each host that lists its games "
+ "(Steam + custom); tap a title to launch it. Works once you've paired — no "
+ "extra host setup.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var controllersSection: some View { @ViewBuilder var controllersSection: some View {
Section { Section {
@@ -537,37 +538,24 @@ extension SettingsView {
controllerRow(controller) controllerRow(controller)
} }
} }
described("One controller is forwarded as player 1 — Automatic picks the most "
+ "recently connected.") {
Picker("Use controller", selection: $gamepads.preferredID) { Picker("Use controller", selection: $gamepads.preferredID) {
ForEach(controllerOptions, id: \.tag) { option in ForEach(controllerOptions, id: \.tag) { option in
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
}
described("The virtual pad created on the host. Automatic matches your controller "
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
Picker("Controller type", selection: $gamepadType) { Picker("Controller type", selection: $gamepadType) {
ForEach(SettingsOptions.padTypes, id: \.tag) { option in ForEach(SettingsOptions.padTypes, id: \.tag) { option in
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
}
#if os(iOS) #if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad). // iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics { if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
+ "clip-on controllers without motors of their own.") {
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice) Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
} }
}
#endif #endif
#if !os(tvOS) #if !os(tvOS)
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
#endif #endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
Button("Test Controller…") { showControllerTest = true } Button("Test Controller…") { showControllerTest = true }
@@ -577,7 +565,20 @@ extension SettingsView {
} header: { } header: {
Text("Controllers") Text("Controllers")
} footer: { } footer: {
Text("Applies from the next session.") // The gamepad-UI blurb is appended here, not merged into the shared
// `controllersFooter` constant tvOS's `tvBody` reuses that exact string (line ~348)
// for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter)
#if os(iOS)
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Text(Self.deviceRumbleFooter)
}
#endif
#if !os(tvOS)
Text(Self.gamepadUIFooter)
#endif
}
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -9,31 +9,6 @@ import PunktfunkKit
import SwiftUI import SwiftUI
extension SettingsView { extension SettingsView {
// MARK: - Described rows (the 2026-07 revamp's field idiom)
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
/// directly under it. This replaced the per-section footer paragraphs a description the eye
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
/// choice.
@ViewBuilder
func described<Content: View>(
_ caption: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 5) {
content()
Text(caption)
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
// Cap the caption's line length well short of the cell: a full-width caption runs
// its text right up to the control column (toggles especially), reading as one
// colliding block. ~46 chars/line also just measures better.
.frame(maxWidth: 360, alignment: .leading)
}
.padding(.vertical, 2)
}
// MARK: - Bitrate // MARK: - Bitrate
/// Slider domain, log-scale: the useful range spans three orders of magnitude /// Slider domain, log-scale: the useful range spans three orders of magnitude
@@ -42,7 +17,6 @@ extension SettingsView {
private static let minSliderKbps = 2_000.0 private static let minSliderKbps = 2_000.0
private static let maxSliderKbps = 3_000_000.0 private static let maxSliderKbps = 3_000_000.0
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
static let bitrateFooter = static let bitrateFooter =
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice " "Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
+ "to its supported range. Run a speed test from a host card's context menu to " + "to its supported range. Run a speed test from a host card's context menu to "
@@ -79,27 +53,55 @@ extension SettingsView {
// MARK: - Statistics // MARK: - Statistics
static var statisticsDescription: String { static var statisticsFooter: String {
let base = "Live session stats in a corner overlay — Compact is a one-line pill, " let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
+ "Detailed adds the latency stage breakdown." + "pill, Normal adds resolution and latency, Detailed adds the latency stage "
+ "breakdown."
#if os(macOS) #if os(macOS)
return base + " ⌃⌥⇧S cycles the tiers any time." return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
#elseif os(iOS) #elseif os(iOS)
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time." return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
+ "any time."
#else #else
return base return base
#endif #endif
} }
// MARK: - Audio
static var audioFooter: String {
#if os(macOS)
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
+ "next session."
#else
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
+ "Applies from the next session."
#endif
}
// MARK: - Controllers // MARK: - Controllers
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
static let controllersFooter = static let controllersFooter =
"One controller is forwarded as player 1 — Automatic picks the most recently " "One controller is forwarded as player 1 — Automatic picks the most recently "
+ "connected. Type is the virtual pad the host creates; Automatic matches your " + "connected. Type is the virtual pad the host creates; Automatic matches your "
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). " + "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session." + "Applies from the next session."
#if os(iOS)
static let deviceRumbleFooter =
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
+ "from the next session."
#endif
#if !os(tvOS)
static let gamepadUIFooter =
"When a controller connects, the host list and library switch to a controller-"
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
+ "off to always use the standard layout."
#endif
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`). /// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
var controllerOptions: [(label: String, tag: String)] { var controllerOptions: [(label: String, tag: String)] {
SettingsOptions.controllerOptions(gamepads) SettingsOptions.controllerOptions(gamepads)
@@ -2,15 +2,13 @@
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device // deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
// downscales supersampling for sharpness, or under-rendering for a lighter host/link). // downscales supersampling for sharpness, or under-rendering for a lighter host/link).
// //
// Navigation differs per platform, but all three follow the same category map (General = // Navigation differs per platform, but all three group the same categories (General, Display,
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers, // Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
// About see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an // an adaptive NavigationSplitView a category sidebar + detail pane on iPad, auto-collapsing to
// adaptive NavigationSplitView a category sidebar + detail pane on iPad, auto-collapsing to
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a // a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
// focus-native pushed-picker layout in the same order. The individual sections // focus-native pushed-picker layout. The individual sections (`streamModeSection`,
// (`resolutionSection`, `audioSection`, ) are shared across all three so a setting is defined // `audioSection`, ) are shared across all three so a setting is defined exactly once they
// exactly once they live in SettingsView+Sections.swift, with their helpers (including the // live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
// per-field `described` caption idiom) in SettingsView+Support.swift.
#if os(macOS) #if os(macOS)
import AppKit import AppKit
@@ -35,12 +33,9 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority = @AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
SettingsOptions.presentPriorityDefault
@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
@@ -125,32 +120,27 @@ struct SettingsView: View {
#if os(macOS) #if os(macOS)
private var macBody: some View { private var macBody: some View {
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
// picture (resolution lives here), Input = keyboard & mouse.
TabView { TabView {
Form { Form {
sessionSection streamModeSection
overlaySection inputSection
librarySection compositorSection
wakeSection
} }
.formStyle(.grouped) .formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
Form { Form {
resolutionSection presenterSection
qualitySection hdrSection
presentationSection vrrSection
hostOutputSection vsyncSection
windowSection
statisticsSection
} }
.formStyle(.grouped) .formStyle(.grouped)
.tabItem { Label("Display", systemImage: "display") } .tabItem { Label("Display", systemImage: "display") }
Form {
inputSection
}
.formStyle(.grouped)
.tabItem { Label("Input", systemImage: "keyboard") }
Form { Form {
audioSection audioSection
} }
@@ -178,10 +168,16 @@ struct SettingsView: View {
.onDisappear { gamepads.stopDiscovery() } .onDisappear { gamepads.stopDiscovery() }
.tabItem { Label("Controllers", systemImage: "gamecontroller") } .tabItem { Label("Controllers", systemImage: "gamecontroller") }
Form {
experimentalSection
}
.formStyle(.grouped)
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
AcknowledgementsView() AcknowledgementsView()
.tabItem { Label("About", systemImage: "info.circle") } .tabItem { Label("About", systemImage: "info.circle") }
} }
.frame(width: 500, height: 520) .frame(width: 480, height: 460)
} }
#endif #endif
@@ -256,31 +252,26 @@ struct SettingsView: View {
switch category { switch category {
case .general: case .general:
Form { Form {
sessionSection streamModeSection
overlaySection pointerSection
librarySection inputSection
compositorSection
wakeSection
keepAliveSection // iOS-only content; empty on tvOS
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle("General") .navigationTitle("General")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
case .display: case .display:
Form { Form {
resolutionSection presenterSection
qualitySection hdrSection
presentationSection vrrSection
hostOutputSection statisticsSection
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle("Display") .navigationTitle("Display")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
case .input:
Form {
pointerSection
inputSection
}
.formStyle(.grouped)
.navigationTitle("Input")
.navigationBarTitleDisplayMode(.inline)
case .audio: case .audio:
Form { audioSection } Form { audioSection }
.formStyle(.grouped) .formStyle(.grouped)
@@ -291,6 +282,11 @@ struct SettingsView: View {
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle("Controllers") .navigationTitle("Controllers")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
case .advanced:
Form { experimentalSection }
.formStyle(.grouped)
.navigationTitle("Advanced")
.navigationBarTitleDisplayMode(.inline)
case .about: case .about:
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the // Already a full scrollable view that sets its own "Acknowledgements" title; pin the
// display mode inline to match the five sibling detail pages (it would otherwise inherit // display mode inline to match the five sibling detail pages (it would otherwise inherit
@@ -336,16 +332,6 @@ struct SettingsView: View {
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" }) Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
} }
/// One cluster caption, TV-legible the 10-foot analogue of the touch/desktop per-row
/// `described` captions (per-row text doesn't scale to TV type sizes).
private func tvCaption(_ text: String) -> some View {
Text(text)
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
}
private var tvBody: some View { private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)" let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds let bounds = UIScreen.main.nativeBounds
@@ -358,9 +344,6 @@ struct SettingsView: View {
if !options.contains(where: { $0.tag == currentTag }) { if !options.contains(where: { $0.tag == currentTag }) {
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0) options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
} }
// Row order mirrors the touch/desktop category map: Display (mode quality
// presentation host output), then Audio, General, Statistics, Controllers with one
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
return ScrollView { return ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
@@ -372,41 +355,37 @@ struct SettingsView: View {
title: "Bitrate", title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps), options: SettingsOptions.bitrateOptions(current: bitrateKbps),
selection: $bitrateKbps) selection: $bitrateKbps)
TVSelectionRow(
title: "Audio channels",
options: SettingsOptions.audioChannels,
selection: $audioChannels)
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size .font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange) .foregroundStyle(.orange)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor)
TVSelectionRow(
title: "Presenter",
options: SettingsOptions.presenters,
selection: $presenter)
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
TVSelectionRow(
title: "Prioritize",
options: SettingsOptions.presentPriorities,
selection: $presentPriority)
if presentPriority == "smooth" {
TVSelectionRow(
title: "Smoothness buffer",
options: SettingsOptions.smoothBuffers(refreshHz: hz),
selection: $smoothBuffer)
}
TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor)
tvCaption("The host drives a real output at exactly the chosen mode. "
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
+ "Smoothness buffers a few to even out network hiccups. A specific "
+ "compositor is honored only if available on the host.")
TVSelectionRow(
title: "Audio channels",
options: SettingsOptions.audioChannels,
selection: $audioChannels)
TVSelectionRow( TVSelectionRow(
title: "Auto-wake on connect", title: "Auto-wake on connect",
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag) options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for " Text("The host creates a virtual output at exactly this mode — native "
+ "it before streaming.") + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
+ "sleeping saved host and waits for it before streaming.")
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
TVSelectionRow( TVSelectionRow(
title: "Statistics overlay", title: "Statistics overlay",
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw) options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
@@ -426,7 +405,11 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Gamepad-optimized browsing", title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag) options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
tvCaption(Self.controllersFooter) Text(Self.controllersFooter)
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
NavigationLink("Acknowledgements") { AcknowledgementsView() } NavigationLink("Acknowledgements") { AcknowledgementsView() }
.padding(.top, 8) .padding(.top, 8)
} }
@@ -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:
@@ -57,8 +57,30 @@ public enum BrandFont {
} }
} }
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget public extension Color {
// extension links Shared alone and must render the same purple. /// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
/// independent of the asset-catalog accent `Color.accentColor` resolution is environment- and
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
static let brand: Color = {
#if canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#elseif canImport(AppKit)
return Color(NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#else
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
#endif
}()
}
public extension Font { public extension Font {
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`. /// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
@@ -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,48 +591,17 @@ 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
/// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a
/// drawable exists. The link vends from the layer's CURRENT config, and the layer starts
/// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's
/// frame isn't even laid out when the link spins up) so leaving all reconciliation to the
/// render path (which needs a frame AND a vended drawable) deadlocks at session start:
/// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and
/// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable]
/// returning nil because allocation failed" every refresh). Called on EVERY frame arrival:
/// drains the same staging the render path drains (both are idempotent about it) and
/// applies size + HDR config, so the next vend always matches the frame about to present
/// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting
/// for a paired present to retag the layer.
func reconcileLayer(decodedSize: CGSize, isHDR: Bool) {
stagingLock.lock()
let targetFromLayout = drawableTarget
let newHdrMeta = pendingHdrMeta
pendingHdrMeta = nil
stagingLock.unlock()
configure(hdr: isHDR)
if let newHdrMeta {
self.lastHdrMeta = newHdrMeta
#if !os(tvOS)
if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) }
#endif
}
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
}
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's; /// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
/// `nextDrawable()` may block up to a frame that wait belongs here, never on main). `isHDR` /// `nextDrawable()` may block up to a frame that wait belongs here, never on main). `isHDR`
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
@@ -742,15 +617,10 @@ public final class MetalVideoPresenter {
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which /// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately /// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
/// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B). /// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B).
///
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
/// render into instead of calling `nextDrawable()` see `encodePresent` for the format
/// guard that skips a vend the layer's config outran.
@discardableResult @discardableResult
public func render( public func render(
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false, _ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
presentAtMediaTime: CFTimeInterval? = nil, presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil onPresented: ((Int64?) -> Void)? = nil
) -> Bool { ) -> Bool {
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and // Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
@@ -804,8 +674,7 @@ public final class MetalVideoPresenter {
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
return encodePresent( return encodePresent(
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline, decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable, presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
onPresented: onPresented,
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
// finishes sampling releasing them at scope exit could free the backing mid-read. // finishes sampling releasing them at scope exit could free the backing mid-read.
keepAlive: [luma, chroma, pixelBuffer] keepAlive: [luma, chroma, pixelBuffer]
@@ -824,17 +693,40 @@ public final class MetalVideoPresenter {
func renderPlanar( func renderPlanar(
_ planes: WaveletPlanes, _ planes: WaveletPlanes,
presentAtMediaTime: CFTimeInterval? = nil, presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil onPresented: ((Int64?) -> Void)? = nil
) -> 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).
@@ -850,8 +742,7 @@ public final class MetalVideoPresenter {
return encodePresent( return encodePresent(
decodedSize: CGSize(width: planes.width, height: planes.height), decodedSize: CGSize(width: planes.width, height: planes.height),
targetFromLayout: targetFromLayout, pipeline: planarPipeline, targetFromLayout: targetFromLayout, pipeline: planarPipeline,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable, presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
onPresented: onPresented,
// The ring textures stay valid by ring depth; retaining them here also pins the // The ring textures stay valid by ring depth; retaining them here also pins the
// slot's set until the sample completes (mirrors the biplanar keep-alive). // slot's set until the sample completes (mirrors the biplanar keep-alive).
keepAlive: [planes.y, planes.cb, planes.cr] keepAlive: [planes.y, planes.cb, planes.cr]
@@ -863,20 +754,124 @@ 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.
///
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
/// layer) its pixel format can lag the pipeline's attachment format encoding would be a
/// Metal validation failure. The guard returns false instead: the drawable drops back to
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
private func encodePresent( private func encodePresent(
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState, decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil, presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
onPresented: ((Int64?) -> Void)?,
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
) -> Bool { ) -> Bool {
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by // Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
@@ -889,47 +884,11 @@ public final class MetalVideoPresenter {
// (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine). // (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine).
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize ? targetFromLayout : decodedSize
// Under a provided (link-vended) drawable this sizes the NEXT vend the one in hand
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
if layer.drawableSize != targetSize { layer.drawableSize = targetSize } if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
#if DEBUG #if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize) logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
#endif #endif
#if os(macOS) guard let drawable = layer.nextDrawable(),
// Windowed (composited) the DCP swapID-panic mitigation mechanism (see
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
// vend matches how it will be presented; drained here on the render thread, flipped
// exactly once per mode change.
stagingLock.lock()
let windowedMode = windowedPresentStaged
stagingLock.unlock()
if windowedMode != windowedPresentActive {
windowedPresentActive = windowedMode
layer.presentsWithTransaction = windowedMode == .transaction
if windowedMode != .surface, !surfacePool.isEmpty {
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool at 5K
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
// clears the surface layer's contents on main.
surfacePool.removeAll()
surfacePoolSize = .zero
lastHandedOff = nil
}
presenterLog.info(
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
}
if windowedMode == .surface {
// No image queue at all: render into a pooled IOSurface and swap it into the
// sibling layer's contents. The drawable/queue tail below never runs.
return encodeToSurface(
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
keepAlive: keepAlive, bind: bind)
}
#endif
if let providedDrawable,
providedDrawable.texture.pixelFormat != layer.pixelFormat {
return false // config outran the vend (HDR flip) next vend has the new format
}
guard let drawable = providedDrawable ?? layer.nextDrawable(),
let commandBuffer = queue.makeCommandBuffer() let commandBuffer = queue.makeCommandBuffer()
else { return false } else { return false }
@@ -963,61 +922,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 +929,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(
@@ -1,12 +1,10 @@
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline // Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
// (explicit VTDecompressionSession decode CAMetalLayer) is the default deadline-paced // (explicit VTDecompressionSession decode CAMetalLayer, driven by the hosting view's
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault); // CADisplayLink) is the default glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer the // macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump AVSampleBufferDisplayLayer)
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug. // is the Metal-unavailable / DEBUG fallback. The views own the platform bits capture,
// Stage-1 (StreamPump AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback. // window/scale tracking, and constructing the display link and delegate the shared presenter
// The views own the platform bits capture, window/scale tracking, and constructing the // lifecycle here.
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink)
// and delegate the shared presenter lifecycle here.
// //
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop. // Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
@@ -30,17 +28,15 @@ public final class DisplayLinkProxy: NSObject {
@objc public func tick(_ link: CADisplayLink) { onTick(link) } @objc public func tick(_ link: CADisplayLink) { onTick(link) }
} }
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present /// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// pacing (`PresentPacing` see Stage2Pipeline for the full tradeoff): stage-2 presents on frame /// glass-gated present pacing (`PresentPacing` see Stage2Pipeline for the tradeoff, and why
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into /// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// CAMetalDisplayLink-vended drawables (deadline pacing iOS/tvOS only; see `PresentPacing`'s /// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the /// DEBUG-only diagnostic. Internal (not private) for unit tests.
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable { enum PresenterChoice: Equatable {
case stage1 case stage1
case stage2 case stage2
case stage3 case stage3
case stage4
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first, /// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var) /// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
@@ -54,109 +50,55 @@ enum PresenterChoice: Equatable {
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values, /// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default /// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage an /// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage an
/// explicit "stage2" must stay a faithful A/B of arrival pacing. "stage4" resolves only on /// explicit "stage2" must stay a faithful A/B of arrival pacing.
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
/// deliberately validated there a synced "stage4" value maps back to the platform default.
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? { static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw { switch raw {
case "stage1": return allowStage1 ? .stage1 : nil case "stage1": return allowStage1 ? .stage1 : nil
case "stage2": return .stage2 case "stage2": return .stage2
case "stage3": return .stage3 case "stage3": return .stage3
case "stage4":
#if os(macOS)
return nil
#else
return .stage4
#endif
default: return nil default: return nil
} }
} }
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2). /// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
/// /// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue /// near the stream rate an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
/// (`displaySyncEnabled` is macOS-only API), and at stream rate panel rate an Apple TV's /// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate) /// makes the COMMON case arrival pacing pins the queue at ~`maximumDrawableCount` and every
/// steering the panel to the stream that queue's depth is STICKY: one burst fills it and, /// frame rides ~23 refreshes of it (measured: ~50 ms on Apple TV; 2330 ms at 120 Hz on
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued /// ProMotion iPads the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder /// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
/// read ~30 ms display on arrival (~3 refreshes of queue), 2228 ms glass-gated at depth 2 /// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
/// (a standing queue of 2 the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass /// so arrival is genuinely lowest-latency there.
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
/// drawable per refresh, presented the moment a frame decodes the queue cannot exist and
/// nothing waits on callbacks (see `PresentPacing.deadline`).
///
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
/// (design/apple-presentation-rebuild.md the engine is field-proven on iOS and strictly
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
/// lowest-latency there.
static var platformDefault: PresenterChoice { static var platformDefault: PresenterChoice {
#if os(iOS) || os(tvOS) #if os(tvOS) || os(iOS)
.stage4 .stage3
#else #else
.stage2 .stage2
#endif #endif
} }
} }
/// The user's presentation INTENT what replaced the visible stage picker in the 2026-07
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
///
/// - `.latency` (the default): every frame shows as soon as the display can take it the
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
/// cadence at the cost of `buffer` refresh intervals of added display latency which the HUD
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` 13;
/// the "Automatic" setting (stored 0) currently maps to 2.
///
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
/// env lever. Internal (not private) for unit tests.
enum PresentPriority: Equatable {
case latency
case smooth(buffer: Int)
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
/// anything but "smooth" unset, garbage, a synced unknown future value falls back to
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
guard setting == "smooth" else { return .latency }
let raw = bufferSetting ?? 0
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
}
/// The frame hand-off policy this intent runs (see `FrameStore`).
var storePolicy: FrameStorePolicy {
switch self {
case .latency: return .newestWins
case .smooth(let buffer): return .fifo(capacity: buffer)
}
}
}
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 {
if choice == .stage4 { return .deadline }
if choice == .stage3 { return .glass } if choice == .stage3 { return .glass }
#if os(macOS) #if os(macOS)
if explicit == nil, codec == .pyrowave { return .glass } if explicit == nil, codec == .pyrowave { return .glass }
@@ -164,42 +106,31 @@ final class SessionPresenter {
return .arrival return .arrival
} }
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere. /// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
/// ///
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning /// - iOS/iPadOS: 2 one flip scanning out plus one queued for the next latch. A decoded
/// out + one queued, predicted ~58 ms at 120 Hz) REGRESSED the iPad Pro's display stage to /// frame presents immediately (the gate is open in steady state) and latches the very next
/// 2228 ms vs depth 1's 14: any second gate slot becomes a STANDING queue a burst fills /// vsync, so the present cadence never serializes on the on-glass callback's own latency
/// it, and with presents and latches then running at the same rate the occupancy never /// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded /// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue /// FIFO saturation (2330 ms) stays gone.
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing /// - tvOS: 1 the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
/// that can't queue at all that's stage-4 (`PresentPacing.deadline`), not a deeper gate. /// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
/// - macOS: pinned to 1, env ignored glass pacing exists there as the DCP swapID
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
/// ///
#if os(macOS) /// `PUNKTFUNK_GATE_DEPTH` (13) overrides on iOS/tvOS for on-device A/B, mirroring the other
/// Resolve the windowed (composited) present MECHANISM for this session the DCP /// presenter env levers. Internal (not private) for unit tests.
/// 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
/// stays reproducible on-device; macOS is pinned to 1, env ignored a deeper gate only builds
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int { static func gateDepth(env: String?) -> Int {
#if os(macOS) #if os(macOS)
return 1 return 1
#else #else
if let env, let depth = Int(env), (1...3).contains(depth) { return depth } if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
#if os(tvOS)
return 1 return 1
#else
return 2
#endif
#endif #endif
} }
@@ -208,16 +139,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
@@ -242,7 +167,6 @@ final class SessionPresenter {
endToEndMeter: LatencyMeter?, endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?, onSessionEnd: (@Sendable () -> Void)?,
@@ -251,76 +175,47 @@ final class SessionPresenter {
stop() stop()
self.connection = connection self.connection = connection
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is // Presenter choice the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can // decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes // where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via // pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
// PresenterChoice.platformDefault deadline on iOS/tvOS, arrival on macOS overridable // on macOS see PresenterChoice.platformDefault); the settings picker is the live A/B.
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker // Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
// value is deliberately ignored). The user-facing choice is the INTENT // default (the stage-1 pump below stays the automatic fallback if Metal is missing).
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
// fallback).
#if DEBUG #if DEBUG
let allowStage1 = true let allowStage1 = true
#else #else
let allowStage1 = false let allowStage1 = false
#endif #endif
let explicit = PresenterChoice.explicit( let explicit = PresenterChoice.explicit(
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"], env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1) allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault let choice = explicit ?? PresenterChoice.platformDefault
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
let priority = PresentPriority.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
// display, so the FIFO alone provides the buffering.
#if os(macOS)
let vsyncPaced = priority != .latency && pacing == .arrival
#else
let vsyncPaced = false
#endif
if choice != .stage1, if choice != .stage1,
let pipeline = Stage2Pipeline( let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter, displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter, pacing: Self.pacing(
pacing: pacing, for: choice, explicit: explicit, codec: connection.videoCodec),
gateDepth: Self.gateDepth( gateDepth: Self.gateDepth(
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]), env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
storePolicy: priority.storePolicy,
vsyncPaced: vsyncPaced) {
let metal = pipeline.layer let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
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
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the // (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes). // link's own report of the current refresh period (tracks VRR rate changes).
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
// clock, and every one of its updates re-checks the ring, which IS the retry tick
// a second link would only fight it over the frame-rate hint.
if pacing != .deadline {
let proxy = DisplayLinkProxy { [weak self] link in let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick( self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp, targetMediaTime: link.targetTimestamp,
@@ -329,7 +224,6 @@ final class SessionPresenter {
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:))) let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common) link.add(to: .main, forMode: .common)
stage2Link = link stage2Link = link
}
syncFrameRate(hz: connection.currentMode().refreshHz) syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start( pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
@@ -357,12 +251,7 @@ final class SessionPresenter {
/// rate (it already tracks the display and must NOT be capped to the stream rate). /// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh. /// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
private func syncFrameRate(hz: UInt32) { private func syncFrameRate(hz: UInt32) {
guard hz > 0 else { return } guard hz > 0, let link = stage2Link else { return }
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
// applied from the link's own thread see Stage2Pipeline.setFrameRateHint). A no-op
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
stage2?.setFrameRateHint(hz: Float(hz))
guard let link = stage2Link else { return }
let hzF = Float(hz) let hzF = Float(hz)
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
#if os(macOS) #if os(macOS)
@@ -456,23 +345,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 +384,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
} }
@@ -18,14 +18,11 @@
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one // V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a // period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage. // schedule can never sit far in the future holding drawables hostage.
// Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by // Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on // by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`) // frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into // drawables (the gate depth see PresentPacing + SessionPresenter.gateDepth) so the layer's
// CAMetalDisplayLink-vended drawables the moment a frame decodes deadline pacing, where the // FIFO image queue can never saturate see PresentPacing's doc for the full rationale.
// queue cannot exist at all see PresentPacing's doc for the full rationale. Under deadline
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI). // Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
// //
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and // The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
@@ -44,7 +41,6 @@ import Foundation
import Metal import Metal
import PunktfunkShared import PunktfunkShared
import QuartzCore import QuartzCore
import os
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode /// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for /// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for
@@ -52,127 +48,33 @@ import os
/// stdout is the cheapest reliable capture channel. /// stdout is the cheapest reliable capture channel.
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1" let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") the /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the private final class ReadyRing: @unchecked Sendable {
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
/// replaced the visible stage picker):
///
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring the decoder
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem
/// see SessionPresenter.gateDepth).
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
/// decoder appends; overflow drops the OLDEST (bounded added latency the newest keeps
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once
/// without preroll a steady stream drains every frame on arrival and headroom never builds
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
/// a repeat by omission, while headroom rebuilds). Each buffered frame one refresh interval
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
///
/// Sendable; lock-guarded decoder callbacks and the render thread cross here.
public enum FrameStorePolicy: Sendable, Equatable {
case newestWins
case fifo(capacity: Int)
}
public final class FrameStore<Frame>: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private let capacity: Int // 1 = newest-wins semantics private var frame: ReadyFrame?
private let isFifo: Bool /// Ring submissions since the last `drainSubmitted` the decode rate for the
private var frames: [Frame] = [] /// PUNKTFUNK_PRESENT_DEBUG stat line.
private var prerolled = false
/// Submissions since the last `drainSubmitted` the decode rate for the pf-present line.
private var submitted = 0 private var submitted = 0
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and func submit(_ f: ReadyFrame) {
/// runs-dry that re-armed preroll. lock.lock(); frame = f; submitted += 1; lock.unlock()
private var overflowDrops = 0
private var underflows = 0
public init(policy: FrameStorePolicy) {
switch policy {
case .newestWins:
capacity = 1
isFifo = false
case .fifo(let k):
capacity = max(1, k)
isFifo = true
} }
}
func submit(_ f: Frame) {
lock.lock()
if isFifo {
frames.append(f)
if frames.count > capacity {
frames.removeFirst() // oldest goes bounded latency, the newest keeps flowing
overflowDrops += 1
}
} else {
frames = [f] // newest wins; the replaced frame is the intended drop point
}
submitted += 1
lock.unlock()
}
func drainSubmitted() -> Int { func drainSubmitted() -> Int {
lock.lock(); defer { lock.unlock() }
let n = submitted; submitted = 0; return n
}
func take() -> ReadyFrame? {
lock.lock(); defer { lock.unlock() }
let f = frame; frame = nil; return f
}
/// Return a frame the display link took but could not present (a transient `nextDrawable`
/// failure). Kept only while the slot is still empty a newer decoded frame wins, so
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
/// next damage the stale picture would persist.
func putBack(_ f: ReadyFrame) {
lock.lock() lock.lock()
defer { lock.unlock() } if frame == nil { frame = f }
let n = submitted
submitted = 0
return n
}
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
lock.lock()
defer { lock.unlock() }
let out = (overflowDrops, underflows)
overflowDrops = 0
underflows = 0
return out
}
func take() -> Frame? {
lock.lock()
defer { lock.unlock() }
if isFifo {
if !prerolled {
guard frames.count >= capacity else { return nil } // still building headroom
prerolled = true
}
guard !frames.isEmpty else {
underflows += 1 // ran dry repeat by omission, rebuild headroom
prerolled = false
return nil
}
return frames.removeFirst()
}
let f = frames.first
frames.removeAll(keepingCapacity: true)
return f
}
/// Return a frame the render thread took but could not present (no drawable yet, or a
/// transient render failure). Newest-wins keeps it only while the slot is still empty a
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
/// the next damage the stale picture would persist.
func putBack(_ f: Frame) {
lock.lock()
if isFifo {
frames.insert(f, at: 0)
} else if frames.isEmpty {
frames = [f]
}
lock.unlock() lock.unlock()
} }
} }
@@ -215,24 +117,13 @@ private final class VsyncClock: @unchecked Sendable {
/// ~23 refreshes of queue (the measured 2330 ms display stage on 120 Hz ProMotion panels), and /// ~23 refreshes of queue (the measured 2330 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the /// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports). /// "fixed-interval" jitter reports).
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but- /// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; depth 1 see `SessionPresenter.gateDepth` /// undisplayed drawables in flight (`PresentGate`; the depth 1 or 2 is per-platform, see
/// for why deeper is a regression). The render thread presents only while a gate slot is free /// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile /// drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before /// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
/// present instead of queueing them behind the display the hidden queue latency becomes /// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass /// explicit, correct frame drops.
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only see
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
/// it with the newest decoded frame THE MOMENT either half arrives usually the frame, into
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
/// ///
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst /// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP /// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
@@ -241,122 +132,6 @@ private final class VsyncClock: @unchecked Sendable {
public enum PresentPacing: Sendable { public enum PresentPacing: Sendable {
case arrival case arrival
case glass case glass
case deadline
}
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
/// drawable stash the link thread `put`s each update's vended drawable (replacing an
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
/// Sendable; lock-guarded.
final class LatestBox<T>: @unchecked Sendable {
private let lock = NSLock()
private var value: T?
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
func putBack(_ v: T) {
lock.lock()
if value == nil { value = v }
lock.unlock()
}
func take() -> T? {
lock.lock()
defer { lock.unlock() }
let v = value
value = nil
return v
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
/// floor is PINNED at the stream rate no idle ramp-down: with a low floor the link idles toward
/// it on a static scene (infinite GOP no frames), and the first damage frame after idle would
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
private final class FrameRateHint: @unchecked Sendable {
private let lock = NSLock()
private var pending: CAFrameRateRange?
func stage(hz: Float) {
guard hz > 0 else { return }
lock.lock()
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
lock.unlock()
}
func drain() -> CAFrameRateRange? {
lock.lock()
defer { lock.unlock() }
let p = pending
pending = nil
return p
}
}
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
/// vended drawable (newest wins) and nudges the render thread which also wakes on decoder
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline the
/// same no-self-capture rule as the pump/render threads.
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let stash: LatestBox<CAMetalDrawable>
private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint
private let stats: PresentDebugStats?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
/// would read ~1 under direct-to-display, tracks VRR rate changes.
private let floorMeter: LatencyMeter?
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert
/// reads 1 while vendLeadMs sits at ~2 periods the scheduler ignores the request while
/// the layer is composited (the promotion hunt); reads 2 the system clamped it outright.
private var loggedEffective = false
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
) {
self.stash = stash
self.renderSignal = renderSignal
self.hint = hint
self.stats = stats
self.floorMeter = floorMeter
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range
}
// Re-assert the minimum-latency request every update (cheap compare): it was set once
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// thing the vendLeadMs stat exists to catch belt and braces.
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
if !loggedEffective {
loggedEffective = true
let range = link.preferredFrameRateRange
let msg = String(
format: "deadline link up: effective preferredFrameLatency=%.2f "
+ "range=%.0f-%.0f preferred=%.0f",
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
presentLog.info("\(msg, privacy: .public)")
}
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
floorMeter.record(
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
}
stash.put(update.drawable)
renderSignal.signal()
}
} }
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables. /// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
@@ -431,20 +206,10 @@ final class PresentGate: @unchecked Sendable {
private final class PresentDebugStats: @unchecked Sendable { private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var last = CACurrentMediaTime() private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0 private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
private var maxRenderMs = 0.0 private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0 private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = [] private var glassDeltasMs: [Double] = []
/// Present-issue on-glass delay per frame (system presentedTime minus the render call's
/// start) the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
/// a healthy latch reads under one period.
private var latchMs: [Double] = []
/// Deadline pacing: the link's own pipeline depth `targetPresentationTimestamp - now` at
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
private var vendLeadMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct /// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a /// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth). /// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
@@ -458,14 +223,6 @@ private final class PresentDebugStats: @unchecked Sendable {
/// is normal, it just shows the gate working. /// is normal, it just shows the gate working.
func gatedWake() { lock.lock(); gated += 1; lock.unlock() } func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
/// drawable yet the frame presents on the link's next update. A high count just means
/// decode outruns the link's phase; the wait is bounded by one refresh.
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) { func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock() lock.lock()
if rendered { if rendered {
@@ -479,61 +236,42 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock() lock.unlock()
} }
func presented(atNs: Int64?, issuedNs: Int64) { func presented(atNs: Int64?) {
lock.lock() lock.lock()
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
if let atNs { if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) } if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
} else { } else {
dropped += 1 dropped += 1
} }
lock.unlock() lock.unlock()
} }
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) { func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
lock.lock() lock.lock()
let now = CACurrentMediaTime() let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return } guard now - last >= 1 else { lock.unlock(); return }
last = now last = now
let decoded = ring.drainSubmitted() let decoded = ring.drainSubmitted()
let smoothing = ring.drainSmoothing()
let deltas = glassDeltasMs.sorted() let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2] let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0 let dMax = deltas.last ?? 0
let latches = latchMs.sorted()
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
let latchMax = latches.last ?? 0
let vends = vendLeadMs.sorted()
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
let vendMax = vends.last ?? 0
let inflightMax = maxInFlight let inflightMax = maxInFlight
let line = String( let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d " format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d " + "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f " decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
+ "vendLeadMs p50=%.2f max=%.2f", gate?.drainForced() ?? 0, p50, dMax, deltas.count)
decoded, ok, failed, empty, gated, noDrawable, dropped, ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0 maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true) glassDeltasMs.removeAll(keepingCapacity: true)
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock() lock.unlock()
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
if presentDebug {
print(line) print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
} }
} }
}
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's /// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in /// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
@@ -558,27 +296,18 @@ private final class DecodeReport: @unchecked Sendable {
} }
public final class Stage2Pipeline { public final class Stage2Pipeline {
private let ring: FrameStore<ReadyFrame> private let ring = ReadyRing()
private let presenter: MetalVideoPresenter private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder private let decoder: VideoDecoder
/// Present cadence `.arrival` (stage-2), `.glass` (stage-3, the present gate) or /// Present cadence `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime; /// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
/// SessionPresenter resolves it per session (see PresentPacing).
private let pacing: PresentPacing private let pacing: PresentPacing
/// The glass gate's in-flight present budget (`PresentGate` capacity) meaningful only under /// The glass gate's in-flight present budget (`PresentGate` capacity) meaningful only under
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`). /// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
private let gateDepth: Int private let gateDepth: Int
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
private let vsyncPaced: Bool
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
/// The measured OS present floor (deadline pacing only): each link update's vendglass lead
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
/// numbers the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
private let presentFloorMeter: LatencyMeter?
private let recovery = KeyframeRecovery() private let recovery = KeyframeRecovery()
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start` /// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
/// binds the live connection + arming flag (see DecodeReport). /// binds the live connection + arming flag (see DecodeReport).
@@ -609,9 +338,6 @@ public final class Stage2Pipeline {
private let vsyncClock = VsyncClock() private let vsyncClock = VsyncClock()
private let renderStopped = DispatchSemaphore(value: 0) private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
/// Created unconditionally (cheap); only the deadline link thread drains it.
private let frameRateHint = FrameRateHint()
/// The Metal layer the hosting view installs + sizes. /// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer } public var layer: CAMetalLayer { presenter.layer }
@@ -628,22 +354,16 @@ public final class Stage2Pipeline {
endToEndMeter: LatencyMeter?, endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
pacing: PresentPacing = .arrival, pacing: PresentPacing = .arrival,
gateDepth: Int = 1, gateDepth: Int = 1
storePolicy: FrameStorePolicy = .newestWins,
vsyncPaced: Bool = false
) { ) {
guard let presenter = MetalVideoPresenter.make() else { return nil } guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter self.presenter = presenter
self.pacing = pacing self.pacing = pacing
self.gateDepth = gateDepth self.gateDepth = gateDepth
self.vsyncPaced = vsyncPaced
self.ring = FrameStore(policy: storePolicy)
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
let ring = ring let ring = ring
let recovery = recovery let recovery = recovery
let renderSignal = renderSignal let renderSignal = renderSignal
@@ -818,17 +538,6 @@ public final class Stage2Pipeline {
pumpJoinable = true pumpJoinable = true
thread.start() thread.start()
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
// vends the drawables and its per-refresh updates co-drive the render thread see
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
}
// The render thread: one present per display-link signal. It owns every layer format/colour/ // The render thread: one present per display-link signal. It owns every layer format/colour/
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on, // drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is // nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
@@ -843,21 +552,16 @@ public final class Stage2Pipeline {
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. // lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
// Resolved once per session. // Resolved once per session.
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"] let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling the FIFO store must drain let vsyncEnabled = presentMode == "vsync"
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
let vsyncPaced = vsyncPaced
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|| (presentMode != "immediate" || (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync)) && UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock let vsyncClock = vsyncClock
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local // Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
// (like the ring) so neither the render thread nor the presented handlers capture `self`. // (like the ring) so neither the render thread nor the presented handlers capture `self`.
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
let renderThread = Thread { let renderThread = Thread {
defer { renderStopped.signal() } defer { renderStopped.signal() }
// macOS smoothness: the vsync this thread last presented onto at most ONE present
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
var lastPresentTarget: CFTimeInterval = 0
// Every iteration drains its own autorelease pool (`return` = the old `continue`): // Every iteration drains its own autorelease pool (`return` = the old `continue`):
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable // this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable
// without a per-iteration pool every presented frame's drawable object (plus its // without a per-iteration pool every presented frame's drawable object (plus its
@@ -867,15 +571,6 @@ public final class Stage2Pipeline {
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
return return
} }
// Smoothness pacing: this vsync's present slot already taken the frame stays
// in the store, and the next display-link tick re-signals. (Tolerance well under
// any refresh period; a stale clock nil target no dedup, present flows.)
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
abs(t - lastPresentTarget) < 0.002 {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
// Stage-3: while a present is in flight, don't take from the ring at all frames // Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented // keep coalescing there (newest wins, the intended drop point) and the presented
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated // handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
@@ -896,7 +591,6 @@ public final class Stage2Pipeline {
let presentAt = vsyncEnabled let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil ? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
let renderStarted = CACurrentMediaTime() let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in let onGlass: (Int64?) -> Void = { presentedNs in
// Stage-3: the flip reached glass (or was dropped) free the present slot, // Stage-3: the flip reached glass (or was dropped) free the present slot,
// then re-signal so the freshest waiting ring frame goes out immediately. // then re-signal so the freshest waiting ring frame goes out immediately.
@@ -914,7 +608,7 @@ public final class Stage2Pipeline {
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME, // Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies. // so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs) debugStats?.presented(atNs: presentedNs)
} }
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the // One present tail, two decode sources: the VideoToolbox biplanar buffer or the
// PyroWave Metal planes the ring, pacing and meters are agnostic to which. // PyroWave Metal planes the ring, pacing and meters are agnostic to which.
@@ -933,8 +627,6 @@ public final class Stage2Pipeline {
if !rendered { if !rendered {
gate?.release() // no present registered its handler will never fire gate?.release() // no present registered its handler will never fire
ring.putBack(frame) ring.putBack(frame)
} else if vsyncPaced, let presentAt {
lastPresentTarget = presentAt // this vsync's slot is now taken
} }
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
} } } }
@@ -945,168 +637,16 @@ public final class Stage2Pipeline {
renderThread.start() renderThread.start()
} }
/// Deadline pacing's present half (stage-4 see `PresentPacing.deadline`): a
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
/// moment either half completes the pair the common case is a decoded frame presenting
/// instantly into an already-vended drawable, which the system then latches at the upcoming
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
/// is never stale by more than a refresh while the link runs.
///
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
/// ever touched there the frame-rate hint crosses via `FrameRateHint`); the link thread's
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
/// autoreleased like a `nextDrawable()` one see the render loop's identical rule); the
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
let token = token
let ring = ring
let renderSignal = renderSignal
let renderStopped = renderStopped
let presenter = presenter
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
let floorMeter = presentFloorMeter
// The link starts LAZILY the render thread triggers this after the FIRST decoded
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
// drawableSize for the whole connect window: every vend fails allocation and the system
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
// refresh until the first frame arrives. Before that frame there is nothing to present
// anyway, and the first frame waits at most one refresh for the first vend.
let startLink: () -> Void = {
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
link.add(to: RunLoop.current, forMode: .default)
while !token.isStopped {
autoreleasepool {
_ = RunLoop.current.run(
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
}
}
link.invalidate()
}
linkThread.name = "punktfunk-stage4-link"
linkThread.qualityOfService = .userInteractive
linkThread.start()
}
let renderThread = Thread {
defer { renderStopped.signal() }
// Whether startLink ran render-thread confined (only this thread triggers it).
var linkLive = false
// Per-iteration autorelease pool same contract as the arrival/glass loop (the
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
while !token.isStopped { autoreleasepool {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
// Present needs the PAIR frame first. The frame drives the layer reconcile,
// which must run even when NO drawable is vended yet: the link vends from the
// layer's CURRENT config, so drawableSize/format have to be right before a vend
// can succeed at all (see reconcileLayer the session-start bootstrap, where
// the layer still has its initial 0×0 size and every vend fails allocation).
guard !token.isStopped, let frame = ring.take() else {
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
switch frame.image {
case .video(let pixelBuffer, let isHDR):
presenter.reconcileLayer(
decodedSize: CGSize(
width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer)),
isHDR: isHDR)
case .planar(let planes):
presenter.reconcileLayer(
decodedSize: CGSize(width: planes.width, height: planes.height),
isHDR: planes.pq)
}
// First frame: the layer now has a real config start vending (see startLink).
if !linkLive {
linkLive = true
startLink()
}
guard let drawable = stash.take() else {
// No vend yet (session start: the reconcile above just unblocked the
// allocator, the link's next update delivers; steady state: decode beat the
// link's phase). putBack keeps newest-wins a fresher decode replaces this
// frame while it waits, and the update's signal retries the pairing.
ring.putBack(frame)
debugStats?.noDrawableWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
let rendered: Bool
switch frame.image {
case .video(let pixelBuffer, let isHDR):
rendered = presenter.render(
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
case .planar(let planes):
rendered = presenter.renderPlanar(
planes, into: drawable, onPresented: onGlass)
}
debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered {
// The vended drawable is spent either way (an unused/mismatched one drops
// back to the pool); the frame retries on the link's next vend. A format
// mismatch (mid-session HDR flip caught between the layer reconfigure and
// the next vend) self-heals the same way see encodePresent's guard.
ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: nil)
} }
}
renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
renderJoinable = true
renderThread.start()
}
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling) /// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
/// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is /// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is
/// (see the header) it only retries a frame a transient `nextDrawable` failure put back into /// (see the header) it only retries a frame a transient `nextDrawable` failure put back into
/// the ring, which matters under the host's infinite GOP where a static scene sends no /// the ring, which matters under the host's infinite GOP where a static scene sends no
/// replacement frame. Arrival/glass pacing only deadline sessions have no CADisplayLink /// replacement frame.
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) { public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period) vsyncClock.set(target: targetMediaTime, period: period)
renderSignal.signal() renderSignal.signal()
} }
/// MAIN thread (SessionPresenter session start + every layout/Reconfigure): hint the
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
/// is the hinted link.
public func setFrameRateHint(hz: Float) {
frameRateHint.stage(hz: hz)
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see /// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`). /// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) { public func setDrawableTarget(_ size: CGSize) {
@@ -1114,15 +654,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`
@@ -1169,7 +709,7 @@ public final class Stage2Pipeline {
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline). /// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
private static func makePyroWavePump( private static func makePyroWavePump(
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore, connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore, ring: ReadyRing, renderSignal: DispatchSemaphore,
device: MTLDevice, queue: MTLCommandQueue, device: MTLDevice, queue: MTLCommandQueue,
decodeMeter: LatencyMeter?, decodeMeter: LatencyMeter?,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -1213,9 +753,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
@@ -65,21 +65,19 @@ public enum Stage444Probe {
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false } guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
var produced: OSType = 0 var produced: OSType = 0
// SYNCHRONOUS decode no `._EnableAsynchronousDecompression`, so the output callback let done = DispatchSemaphore(value: 0)
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
// nothing from decode parallelism; the lazy statics still cache the result.
let status = VTDecompressionSessionDecodeFrame( let status = VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, session, sampleBuffer: sample,
flags: [], infoFlagsOut: nil flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in ) { status, _, imageBuffer, _, _ in
if status == noErr, let imageBuffer { if status == noErr, let imageBuffer {
produced = CVPixelBufferGetPixelFormatType(imageBuffer) produced = CVPixelBufferGetPixelFormatType(imageBuffer)
} }
done.signal()
} }
guard status == noErr else { return false } guard status == noErr else { return false }
VTDecompressionSessionWaitForAsynchronousFrames(session)
_ = done.wait(timeout: .now() + 1.0)
return produced == want || produced == fullRangeSibling return produced == want || produced == fullRangeSibling
} }
} }
@@ -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,
@@ -93,7 +93,6 @@ public struct StreamView: NSViewRepresentable {
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI. /// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust /// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
@@ -116,8 +115,7 @@ public struct StreamView: NSViewRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil, onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil
presentFloorMeter: LatencyMeter? = nil
) { ) {
self.connection = connection self.connection = connection
self.captureEnabled = captureEnabled self.captureEnabled = captureEnabled
@@ -130,7 +128,6 @@ public struct StreamView: NSViewRepresentable {
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
} }
public func makeNSView(context: Context) -> StreamLayerView { public func makeNSView(context: Context) -> StreamLayerView {
@@ -141,7 +138,6 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -155,7 +151,6 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the // SwiftUI reuses the NSView across state changes repoint the pump only when the
@@ -177,7 +172,6 @@ public final class StreamLayerView: NSView {
var endToEndMeter: LatencyMeter? var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter? var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter? var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
@@ -667,7 +661,6 @@ public final class StreamLayerView: NSView {
endToEndMeter: endToEndMeter, endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter, displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) }, makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd, onSessionEnd: onSessionEnd,
@@ -700,9 +693,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.
@@ -61,7 +61,6 @@ public struct StreamView: UIViewControllerRepresentable {
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the /// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
/// captured-state D combo is detected by the macOS NSEvent monitor only); on iOS a /// captured-state D combo is detected by the macOS NSEvent monitor only); on iOS a
@@ -78,8 +77,7 @@ public struct StreamView: UIViewControllerRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil, onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil
presentFloorMeter: LatencyMeter? = nil
) { ) {
self.connection = connection self.connection = connection
self.captureEnabled = captureEnabled self.captureEnabled = captureEnabled
@@ -91,7 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
} }
public func makeUIViewController(context: Context) -> StreamViewController { public func makeUIViewController(context: Context) -> StreamViewController {
@@ -101,7 +98,6 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -114,7 +110,6 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize controller.onDecodedSize = onDecodedSize
if controller.connection !== connection { if controller.connection !== connection {
@@ -150,7 +145,6 @@ public final class StreamViewController: StreamViewControllerBase {
var endToEndMeter: LatencyMeter? var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter? var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter? var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
@@ -412,7 +406,6 @@ public final class StreamViewController: StreamViewControllerBase {
endToEndMeter: endToEndMeter, endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter, displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd, onSessionEnd: onSessionEnd,
@@ -1,30 +0,0 @@
// The brand color, in the dependency-free foundation so EVERY process can use it the widget
// extension links PunktfunkShared alone (never PunktfunkKit's Rust staticlib), and before this
// moved here the Live Activity / widgets fell back to `.tint` = system blue.
import SwiftUI
public extension Color {
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
/// independent of the asset-catalog accent `Color.accentColor` resolution is environment- and
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
static let brand: Color = {
#if canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#elseif canImport(AppKit)
return Color(NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
})
#else
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
#endif
}()
}
@@ -50,36 +50,17 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic /// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices. /// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel" public static let micChannel = "punktfunk.micChannel"
/// LEGACY (2026-07 presentation rebuild design/apple-presentation-rebuild.md): the old /// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// user-visible stage picker's key. No longer read the presenter is resolved from /// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// `presentPriority` below; the stage ladder survives only as the /// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old /// system-layer fallback). Resolved once per session by SessionPresenter;
/// value is documented, not mysterious. /// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// The user's presentation intent: "latency" (default every frame shows as soon as the
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
/// jitter buffer evens the cadence at the cost of added, visible display latency).
/// Resolved once per session by SessionPresenter see PresentPriority.
public static let presentPriority = "punktfunk.presentPriority"
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 13.
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
public static let smoothBuffer = "punktfunk.smoothBuffer"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync /// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes /// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
/// (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,
@@ -4,10 +4,9 @@ import XCTest
import QuartzCore import QuartzCore
@testable import PunktfunkKit @testable import PunktfunkKit
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox` /// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting + /// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only /// release-build stage-1 gate), and the per-platform glass-gate depth.
/// stage-4 gate), and the per-platform glass-gate depth.
final class PresentPacingTests: XCTestCase { final class PresentPacingTests: XCTestCase {
// MARK: - PresentGate // MARK: - PresentGate
@@ -22,10 +21,8 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared") XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
} }
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung no longer a default; see /// Depth 2 (the iOS default): a second present may queue behind the flip scanning out the
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue /// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
/// behind the flip scanning out the bound only bites at the THIRD. One release (a glass
/// callback) reopens exactly one slot.
func testGateDepthTwoAdmitsTwoInFlightPresents() { func testGateDepthTwoAdmitsTwoInFlightPresents() {
let gate = PresentGate(capacity: 2) let gate = PresentGate(capacity: 2)
XCTAssertTrue(gate.tryAcquire(now: 0)) XCTAssertTrue(gate.tryAcquire(now: 0))
@@ -75,143 +72,16 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0) XCTAssertEqual(gate.drainForced(), 0)
} }
// MARK: - PresentPriority (the user-facing latency/smoothness intent)
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
func testPresentPriorityResolution() {
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
.smooth(buffer: 2), "unset buffer = Automatic = 2")
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
.smooth(buffer: 2), "out-of-range buffer = Automatic")
}
/// The intentstore mapping: latency runs the zero-queue newest-wins slot, smoothness the
/// FIFO jitter buffer at the resolved capacity.
func testPresentPriorityStorePolicy() {
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
XCTAssertEqual(
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
}
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
/// restores only into an empty slot the exact pre-rebuild ReadyRing semantics.
func testFrameStoreNewestWins() {
let store = FrameStore<Int>(policy: .newestWins)
XCTAssertNil(store.take())
store.submit(1)
store.submit(2)
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
XCTAssertNil(store.take())
store.putBack(7)
store.submit(8) // a fresh decode beats the putBack
store.putBack(7)
XCTAssertEqual(store.take(), 8)
XCTAssertEqual(store.drainSubmitted(), 3)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 0)
XCTAssertEqual(smoothing.underflows, 0)
}
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity
/// without preroll a steady stream drains on arrival and headroom never builds then pops
/// oldest-first.
func testFrameStoreFifoPrerollsToCapacity() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
store.submit(2)
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
store.submit(3)
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
XCTAssertEqual(store.take(), 3)
}
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
func testFrameStoreFifoOverflowAndUnderflow() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
store.submit(3) // full 1 (the oldest) goes
XCTAssertEqual(store.take(), 2)
XCTAssertEqual(store.take(), 3)
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
store.submit(4)
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
store.submit(5)
XCTAssertEqual(store.take(), 4)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 1)
XCTAssertEqual(smoothing.underflows, 1)
}
/// FIFO putBack reinserts at the FRONT a frame the render thread couldn't present is
/// still the oldest, so present order is preserved.
func testFrameStoreFifoPutBackPreservesOrder() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
let f = store.take()
XCTAssertEqual(f, 1)
store.putBack(f!)
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
XCTAssertEqual(store.take(), 2)
}
// MARK: - LatestBox (stage-4's drawable hand-off)
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
/// layer's pool by release), `take` empties the slot.
func testLatestBoxNewestWins() {
let box = LatestBox<Int>()
XCTAssertNil(box.take())
box.put(1)
box.put(2)
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
XCTAssertNil(box.take(), "take empties the slot")
}
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
/// didn't present must never clobber a fresher one the link vended in between.
func testLatestBoxPutBackNeverClobbersAFresherPut() {
let box = LatestBox<Int>()
box.put(1)
let stale = box.take()
XCTAssertEqual(stale, 1)
box.putBack(stale!)
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
box.put(2)
let taken = box.take()
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
box.putBack(taken!)
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
}
// MARK: - PresenterChoice // MARK: - PresenterChoice
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching /// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
/// platforms where any bounded-FIFO pacing keeps a standing queue tvOS joined in the /// tvOS arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue). /// (sync-off presents don't queue). No selection / garbage falls back to it.
/// No selection / garbage falls back to it.
func testPresenterChoiceFallsBackToPlatformDefault() { func testPresenterChoiceFallsBackToPlatformDefault() {
#if os(iOS) || os(tvOS) #if os(macOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
#else
XCTAssertEqual(PresenterChoice.platformDefault, .stage2) XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
#else
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
#endif #endif
XCTAssertEqual( XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
@@ -236,29 +106,6 @@ final class PresentPacingTests: XCTestCase {
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3) PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
} }
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS whose present path is
/// entangled with the sync-off/DCP-panic saga a synced-over "stage4" value maps back to
/// the platform default instead of engaging an unvalidated pacing.
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
#if os(macOS)
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
#else
XCTAssertEqual(
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
// The env override wins over the persisted setting, both directions.
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
#endif
}
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG /// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
/// builds); a leftover "stage1" value in a release build maps back to the platform default. /// builds); a leftover "stage1" value in a release build maps back to the platform default.
func testPresenterChoiceGatesStage1() { func testPresenterChoiceGatesStage1() {
@@ -309,65 +156,25 @@ final class PresentPacingTests: XCTestCase {
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass) SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
XCTAssertEqual( XCTAssertEqual(
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass) SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
// Stage-4 means deadline regardless of codec or how it was chosen.
XCTAssertEqual(
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
XCTAssertEqual(
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 per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
/// the 2026-07 iPad depth-2 experiment regressed display latency 142228 ms (see /// queued the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass /// there is the swapID-panic mitigation strict serialization is its point, so the env
/// there is the swapID-panic mitigation strict serialization is its point); /// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS. /// values are ignored.
/// Out-of-range/garbage values are ignored.
func testGateDepthPlatformDefaultsAndEnvOverride() { func testGateDepthPlatformDefaultsAndEnvOverride() {
#if os(macOS) #if os(macOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1) XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1") XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
#elseif os(tvOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
#else #else
XCTAssertEqual( XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
SessionPresenter.gateDepth(env: nil), 1, XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
"any depth >1 is a standing queue — one refresh of display latency per slot")
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder lever")
#endif #endif
XCTAssertEqual( XCTAssertEqual(
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil), SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
-50
View File
@@ -55,11 +55,6 @@ pub struct AppModel {
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams /// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
/// run in the session binary, which has its own. /// run in the session binary, which has its own.
pub gamepad: crate::gamepad::GamepadService, pub gamepad: crate::gamepad::GamepadService,
/// Device lists for the settings pickers (GPUs via `punktfunk-session
/// --list-adapters` — the shell deliberately links no Vulkan itself — and audio
/// endpoints via the PipeWire registry), probed once at startup on a worker thread.
/// Empty until the probe lands — empty lists simply hide their pickers.
pub probes: Rc<RefCell<crate::ui_settings::DeviceProbes>>,
hosts: Controller<HostsPage>, hosts: Controller<HostsPage>,
/// One session child at a time — connects while one runs are ignored. /// One session child at a time — connects while one runs are ignored.
busy: bool, busy: bool,
@@ -165,42 +160,6 @@ impl SimpleComponent for AppModel {
} }
let settings = Rc::new(RefCell::new(Settings::load())); let settings = Rc::new(RefCell::new(Settings::load()));
// Device lists for the settings pickers: probe in the background, ready long
// before the dialog opens. A missing session binary or absent PipeWire just
// leaves the corresponding list empty (and its picker hidden).
let probes: Rc<RefCell<crate::ui_settings::DeviceProbes>> = Rc::default();
{
let (tx, rx) = async_channel::bounded::<crate::ui_settings::DeviceProbes>(1);
std::thread::spawn(move || {
let adapters: Vec<String> =
std::process::Command::new(crate::spawn::session_binary())
.arg("--list-adapters")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
let _ = tx.send_blocking(crate::ui_settings::DeviceProbes {
adapters,
speakers,
mics,
});
});
let probes = probes.clone();
glib::spawn_future_local(async move {
if let Ok(found) = rx.recv().await {
*probes.borrow_mut() = found;
}
});
}
// Re-apply the persisted forwarded-controller pin (stable key; the service // Re-apply the persisted forwarded-controller pin (stable key; the service
// matches it whenever such a pad connects). // matches it whenever such a pad connects).
{ {
@@ -238,7 +197,6 @@ impl SimpleComponent for AppModel {
settings, settings,
identity, identity,
gamepad: init.gamepad, gamepad: init.gamepad,
probes,
hosts, hosts,
busy: false, busy: false,
wake_fallback: None, wake_fallback: None,
@@ -349,15 +307,8 @@ impl SimpleComponent for AppModel {
// packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep // packet now (fire-and-forget — harmless if it's awake) so a genuinely-asleep
// box is already booting while the dial times out, arm the wake-wait fallback // box is already booting while the dial times out, arm the wake-wait fallback
// for THIS request, and connect immediately. // for THIS request, and connect immediately.
//
// Auto-wake OFF (the Settings toggle, for VPN hosts that look offline when
// they aren't): no packet and no wake-and-wait fallback — the dial either
// succeeds or fails with the normal error. The host-card menu's explicit
// "Wake host" is deliberately not gated.
if self.settings.borrow().auto_wake {
crate::wol::wake(&req.mac, req.addr.parse().ok()); crate::wol::wake(&req.mac, req.addr.parse().ok());
self.wake_fallback = Some(req.clone()); self.wake_fallback = Some(req.clone());
}
sender.input(AppMsg::Connect(req)); sender.input(AppMsg::Connect(req));
} }
} }
@@ -484,7 +435,6 @@ impl SimpleComponent for AppModel {
&self.window, &self.window,
self.settings.clone(), self.settings.clone(),
&self.gamepad, &self.gamepad,
&self.probes.borrow(),
move || { move || {
// The library toggle changes the saved cards' menu — re-render. // The library toggle changes the saved cards' menu — re-render.
let _ = hosts.send(HostsMsg::Refresh); let _ = hosts.send(HostsMsg::Refresh);
+1 -29
View File
@@ -352,7 +352,6 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
paired: false, paired: false,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
} }
match known.save() { match known.save() {
@@ -535,34 +534,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
))); )));
} }
"settings" | "03-settings" => { "settings" | "03-settings" => {
// Mock devices so the shot shows the probe-dependent pickers populated. crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
name: name.to_string(),
description: description.to_string(),
};
let probes = crate::ui_settings::DeviceProbes {
adapters: vec![
"NVIDIA GeForce RTX 4070".to_string(),
"AMD Radeon 780M".to_string(),
],
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
};
let dialog = crate::ui_settings::show(
&ctx.window,
ctx.settings.clone(),
&ctx.gamepad,
&probes,
|| {},
);
// Optional page for the capture (general/display/input/audio/controllers);
// the dialog opens on General otherwise.
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
if !page.is_empty() {
use adw::prelude::PreferencesDialogExt as _;
dialog.set_visible_page_name(&page);
}
}
} }
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()), "trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
"pair" | "05-pair" => { "pair" | "05-pair" => {
+103 -423
View File
@@ -1,9 +1,5 @@
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings //! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns //! capture behavior. Written back to disk when the dialog closes.
//! everything about the picture — with per-field captions in each row's subtitle,
//! dynamic where the meaning depends on the selection (touch mode). Written back to
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
//! rather than as a page.
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
@@ -44,31 +40,14 @@ const GAMEPADS: &[&str] = &[
"steamdeck", "steamdeck",
]; ];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is /// Codec setting values (persisted) paired with their display labels below.
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
/// HEVC when either side can't do it. const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
const CODEC_LABELS: &[&str] = &[
"Automatic",
"HEVC (H.265)",
"H.264 (AVC)",
"AV1",
"PyroWave (wired LAN)",
];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"]; const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the /// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet). /// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"]; const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"]; const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
const TOUCH_MODE_CAPTIONS: &[&str] = &[
"Drives the cursor like a laptop trackpad — tap to click",
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// 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!(
@@ -287,92 +266,22 @@ impl ChoiceRow {
} }
} }
/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
r.set_subtitle(text);
}
}
/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
/// 2.. = explicit sizes) — one line each, see the caption-width note on
/// [`TOUCH_MODE_CAPTIONS`].
fn resolution_caption(i: u32) -> &'static str {
match i {
0 => "The native mode of this monitor, resolved at connect",
1 => "Follows the stream window — resizes renegotiate the host output",
_ => "The host drives a virtual output at exactly this size",
}
}
/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
/// spelled out; everything else shares the soft-preference line.
fn codec_caption(i: u32) -> &'static str {
if CODECS.get(i as usize) == Some(&"pyrowave") {
"Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
} else {
"A preference — the host falls back if it can't encode it"
}
}
/// A settings category page for the dialog's view switcher.
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
adw::PreferencesPage::builder()
// The name addresses the page programmatically (`set_visible_page_name` — the
// screenshot harness's page knob); the title is what the view switcher shows.
.name(title.to_lowercase())
.title(title)
.icon_name(icon)
.build()
}
/// Startup device probes for the pickers — filled by the app shell in the background
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
/// registry); any list may still be empty when the dialog opens, which simply hides
/// that picker.
#[derive(Default)]
pub struct DeviceProbes {
pub adapters: Vec<String>,
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
pub mics: Vec<pf_client_core::audio::AudioDevice>,
}
/// A titled group of rows; `description` (may be empty) is the one form-level note —
/// per-field explanations belong in row subtitles, not here.
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
let g = adw::PreferencesGroup::builder().title(title).build();
if !description.is_empty() {
g.set_description(Some(description));
}
g
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the /// there so the experimental library toggle takes effect without a nav round-trip).
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
pub fn show( pub fn show(
parent: &impl IsA<gtk::Widget>, parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>, settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService, gamepads: &crate::gamepad::GamepadService,
probes: &DeviceProbes,
on_closed: impl Fn() + 'static, on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog { ) {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection // The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it. // subpage onto it.
let dialog = adw::PreferencesDialog::new(); let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences"); dialog.set_title("Preferences");
dialog.set_search_enabled(true);
// Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
// Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
// breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
// can't give the dialog this width it still collapses to the bottom bar on its own.
dialog.set_content_width(830);
let inline = gamescope_session(); let inline = gamescope_session();
let page = adw::PreferencesPage::new();
// ---- Display: Resolution ---- let stream = adw::PreferencesGroup::builder().title("Stream").build();
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the // The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes. // `match_window` flag), then the explicit sizes.
let res_names: Vec<String> = std::iter::once("Native display".to_string()) let res_names: Vec<String> = std::iter::once("Native display".to_string())
@@ -388,13 +297,10 @@ pub fn show(
&dialog, &dialog,
inline, inline,
"Resolution", "Resolution",
resolution_caption(0), "The host creates a virtual output at exactly this size — Match window follows \
the stream window, including mid-stream resizes",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
{
let w = res_row.widget().clone();
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
}
let hz_names: Vec<String> = REFRESH let hz_names: Vec<String> = REFRESH
.iter() .iter()
.map(|&r| { .map(|&r| {
@@ -409,11 +315,9 @@ pub fn show(
&dialog, &dialog,
inline, inline,
"Refresh rate", "Refresh rate",
"Native follows the monitor the window is on", "",
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(), &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
// ---- Display: Quality ----
let scale_names: Vec<String> = RENDER_SCALES let scale_names: Vec<String> = RENDER_SCALES
.iter() .iter()
.map(|&s| render_scale_label(s)) .map(|&s| render_scale_label(s))
@@ -422,69 +326,13 @@ pub fn show(
&dialog, &dialog,
inline, inline,
"Render scale", "Render scale",
"Above 1× supersamples for sharpness; below is lighter on the host", "Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
(< 1×) for a lighter host this device resamples to the window",
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(), &scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
codec_caption(0),
CODEC_LABELS,
);
{
let w = codec_row.widget().clone();
codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
}
let hdr_row = adw::SwitchRow::builder()
.title("10-bit HDR")
.subtitle(
"Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
the display supports it, tone-mapped otherwise",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
"Video decoder",
"Automatic picks the best hardware decode, then software",
&["Automatic", "Vulkan Video", "VAAPI", "Software"],
);
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
let saved_adapter = settings.borrow().adapter.clone();
let mut gpu_names = vec!["Automatic".to_string()];
let mut gpu_keys: Vec<String> = vec![String::new()];
for a in &probes.adapters {
gpu_names.push(a.clone());
gpu_keys.push(a.clone());
}
if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
gpu_names.push(format!("{saved_adapter} (not detected)"));
gpu_keys.push(saved_adapter.clone());
}
let gpu_row = (gpu_keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
"GPU",
"Decodes and presents the stream",
&gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let i = gpu_keys
.iter()
.position(|k| k == &saved_adapter)
.unwrap_or(0);
row.set_selected(i as u32);
row
});
// ---- Display: Host output ----
let compositor_row = ChoiceRow::new( let compositor_row = ChoiceRow::new(
&dialog, &dialog,
inline, inline,
@@ -498,19 +346,19 @@ pub fn show(
"gamescope", "gamescope",
], ],
); );
let decoder_row = ChoiceRow::new(
// ---- General ---- &dialog,
let fullscreen_row = adw::SwitchRow::builder() inline,
.title("Start streams in fullscreen") "Video decoder",
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") "Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
.build(); Vulkan Video on NVIDIA), falling back to software",
let wake_row = adw::SwitchRow::builder() &[
.title("Auto-wake on connect") "Automatic (hardware → software)",
.subtitle( "Vulkan Video",
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \ "VAAPI",
off if hosts behind a VPN look offline when they aren't", "Software",
) ],
.build(); );
let stats_row = ChoiceRow::new( let stats_row = ChoiceRow::new(
&dialog, &dialog,
inline, inline,
@@ -518,101 +366,20 @@ pub fn show(
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live", "Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"], &["Off", "Compact", "Normal", "Detailed"],
); );
let library_row = adw::SwitchRow::builder() let fullscreen_row = adw::SwitchRow::builder()
.title("Show game library") .title("Start streams in fullscreen")
.subtitle( .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
and launch one directly. No extra host setup",
)
.build(); .build();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(scale_row.widget());
stream.add(&bitrate_row);
stream.add(compositor_row.widget());
stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(stats_row.widget());
// ---- Input ---- let input = adw::PreferencesGroup::builder().title("Input").build();
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
TOUCH_MODE_CAPTIONS[0],
TOUCH_MODE_LABELS,
);
// Dynamic caption: describe the SELECTED mode, not all three at once.
{
let w = touch_row.widget().clone();
touch_row.connect_changed(move |i| {
let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
});
}
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
let invert_row = adw::SwitchRow::builder()
.title("Invert scroll direction")
.subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
.build();
// ---- Audio ----
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Stereo or surround — the host downmixes if its output has fewer",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Sends your microphone to the host's virtual mic")
.build();
// Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
// stored value is the node name. Hidden when the probe found nothing; a saved
// device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
let dev_row = |saved: String,
devs: &[pf_client_core::audio::AudioDevice],
title: &str,
subtitle: &str| {
let mut names = vec!["System default".to_string()];
let mut keys = vec![String::new()];
for d in devs {
names.push(d.description.clone());
keys.push(d.name.clone());
}
if !saved.is_empty() && !keys.contains(&saved) {
names.push(format!("{saved} (not detected)"));
keys.push(saved.clone());
}
let row = (keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
title,
subtitle,
&names.iter().map(String::as_str).collect::<Vec<_>>(),
);
row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
row
});
(row, keys)
};
let (speaker_row, speaker_keys) = dev_row(
settings.borrow().speaker_device.clone(),
&probes.speakers,
"Speaker",
"Host audio plays here — System default follows the desktop",
);
let (micdev_row, micdev_keys) = dev_row(
settings.borrow().mic_device.clone(),
&probes.mics,
"Microphone",
"The input that feeds the host's virtual mic",
);
// The device pick only matters while the mic streams at all.
if let Some(r) = &micdev_row {
let w = r.widget().clone();
w.set_sensitive(mic_row.is_active());
mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
}
// ---- Controllers ----
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
// (Steam's virtual pad skipped); pinning one restricts the session to that single // (Steam's virtual pad skipped); pinning one restricts the session to that single
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`), // controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
@@ -646,7 +413,7 @@ pub fn show(
if pads.is_empty() { if pads.is_empty() {
"No controllers detected" "No controllers detected"
} else { } else {
"Every pad is its own player pick one to force single-player" "All controllers are forwarded, each as its own player; pick one to force single-player"
}, },
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(), &pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
@@ -676,7 +443,7 @@ pub fn show(
&dialog, &dialog,
inline, inline,
"Gamepad type", "Gamepad type",
"The virtual pad on the host — Automatic matches your controller", "The virtual pad the host creates — Automatic matches the physical pad",
&[ &[
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
@@ -686,8 +453,66 @@ pub fn show(
"Steam Deck", "Steam Deck",
], ],
); );
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
Direct pointer jumps to your finger; Touch passthrough sends real touches",
TOUCH_MODE_LABELS,
);
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(forward_row.widget());
input.add(pad_row.widget());
input.add(touch_row.widget());
input.add(&inhibit_row);
// ---- Seed from the current settings ---- let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Request stereo or surround (the host downmixes if its output has fewer)",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
audio.add(surround_row.widget());
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
"Preferred codec — the host falls back if it can't encode this one",
CODEC_LABELS,
);
stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
// Experimental — mirrors the Apple client's Experimental section (wording included).
let experimental = adw::PreferencesGroup::builder()
.title("Experimental")
.build();
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds a “Browse library…” action to each saved host that lists its games \
(Steam + custom) via the host's management API works once you've paired",
)
.build();
experimental.add(&library_row);
// About (with the license/third-party Legal pages) lives in the primary menu now.
page.add(&stream);
page.add(&input);
page.add(&audio);
page.add(&experimental);
// Seed from the current settings.
{ {
let s = settings.borrow(); let s = settings.borrow();
let res_i = if s.match_window { let res_i = if s.match_window {
@@ -700,7 +525,6 @@ pub fn show(
.unwrap_or(0) .unwrap_or(0)
}; };
res_row.set_selected(res_i as u32); res_row.set_selected(res_i as u32);
set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32); hz_row.set_selected(hz_i as u32);
let scale_i = RENDER_SCALES let scale_i = RENDER_SCALES
@@ -716,8 +540,6 @@ pub fn show(
.position(|&t| t == s.touch_mode) .position(|&t| t == s.touch_mode)
.unwrap_or(0); .unwrap_or(0);
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_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
let comp_i = COMPOSITORS let comp_i = COMPOSITORS
.iter() .iter()
.position(|&c| c == s.compositor) .position(|&c| c == s.compositor)
@@ -731,11 +553,8 @@ pub fn show(
.unwrap_or(0); .unwrap_or(0);
stats_row.set_selected(stats_i as u32); stats_row.set_selected(stats_i as u32);
fullscreen_row.set_active(s.fullscreen_on_stream); fullscreen_row.set_active(s.fullscreen_on_stream);
wake_row.set_active(s.auto_wake);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
invert_row.set_active(s.invert_scroll);
mic_row.set_active(s.mic_enabled); mic_row.set_active(s.mic_enabled);
hdr_row.set_active(s.hdr_enabled);
library_row.set_active(s.library_enabled); library_row.set_active(s.library_enabled);
surround_row.set_selected(match s.audio_channels { surround_row.set_selected(match s.audio_channels {
6 => 1, 6 => 1,
@@ -744,104 +563,9 @@ pub fn show(
}); });
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0); let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
codec_row.set_selected(codec_i as u32); codec_row.set_selected(codec_i as u32);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
} }
// ---- Assemble the category pages (the Apple revamp's map) ---- dialog.add(&page);
let general = page("General", "preferences-system-symbolic");
let session_group = group("Session", "");
session_group.add(&fullscreen_row);
session_group.add(&wake_row);
let stats_group = group("Statistics", "");
stats_group.add(stats_row.widget());
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&session_group);
general.add(&stats_group);
general.add(&library_group);
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
resolution_group.add(res_row.widget());
resolution_group.add(hz_row.widget());
let quality_group = group("Quality", "");
quality_group.add(scale_row.widget());
quality_group.add(&bitrate_row);
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(decoder_row.widget());
if let Some(r) = &gpu_row {
quality_group.add(r.widget());
}
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
"Display changes apply from the next session.",
);
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
let touch_group = group("Touch", "");
touch_group.add(touch_row.widget());
// Group titles are Pango markup — the ampersand must be an entity.
let kbm_group = group("Keyboard &amp; mouse", "");
kbm_group.add(&inhibit_row);
kbm_group.add(&invert_row);
input.add(&touch_group);
input.add(&kbm_group);
let audio = page("Audio", "audio-volume-high-symbolic");
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
if let Some(r) = &speaker_row {
audio_group.add(r.widget());
}
audio_group.add(&mic_row);
if let Some(r) = &micdev_row {
audio_group.add(r.widget());
}
audio.add(&audio_group);
let controllers = page("Controllers", "input-gaming-symbolic");
let controllers_group = group("", "");
// The detected-pad list (mirrors the Apple Controllers section): informational rows
// above the pickers, from the same snapshot that feeds the forwarding picker.
if pads.is_empty() {
let none = adw::ActionRow::builder()
.title("No controllers detected")
.css_classes(["dim-label"])
.build();
controllers_group.add(&none);
} else {
for p in &pads {
let row = adw::ActionRow::builder()
.title(&p.name)
.use_markup(false)
.build();
if p.steam_virtual {
row.set_subtitle(
"Steam Input's virtual pad — Automatic skips it while a real pad is connected",
);
} else {
row.set_subtitle(p.kind_label());
}
row.add_prefix(&gtk::Image::from_icon_name("input-gaming-symbolic"));
controllers_group.add(&row);
}
}
controllers_group.add(forward_row.widget());
controllers_group.add(pad_row.widget());
controllers.add(&controllers_group);
dialog.add(&general);
dialog.add(&display);
dialog.add(&input);
dialog.add(&audio);
dialog.add(&controllers);
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut(); let mut s = settings.borrow_mut();
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit. // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
@@ -856,40 +580,19 @@ 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.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();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
if let Some(r) = &gpu_row {
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
}
if let Some(r) = &speaker_row {
s.speaker_device =
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
}
if let Some(r) = &micdev_row {
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
}
s.set_stats_verbosity( s.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)], StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
); );
s.fullscreen_on_stream = fullscreen_row.is_active(); s.fullscreen_on_stream = fullscreen_row.is_active();
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.mic_enabled = mic_row.is_active(); s.mic_enabled = mic_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.audio_channels = match surround_row.selected() { s.audio_channels = match surround_row.selected() {
1 => 6, 1 => 6,
2 => 8, 2 => 8,
@@ -902,7 +605,6 @@ pub fn show(
on_closed(); on_closed();
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
dialog
} }
#[cfg(test)] #[cfg(test)]
@@ -976,17 +678,6 @@ mod tests {
assert_eq!(fired.get(), 1); assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B"); assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// The dynamic-caption hook drives the same subtitle both row shapes expose.
set_row_subtitle(row.widget(), "swapped");
assert_eq!(
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("swapped")
);
// Re-activating shows the check on the new selection (fresh subpage each time). // Re-activating shows the check on the new selection (fresh subpage each time).
row.widget() row.widget()
.downcast_ref::<adw::ActionRow>() .downcast_ref::<adw::ActionRow>()
@@ -1007,16 +698,5 @@ mod tests {
combo.set_selected(0); combo.set_selected(0);
assert_eq!(combo.selected(), 0); assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0); assert_eq!(combo_fired.get(), 0);
// ComboRow derives from ActionRow, so the caption hook reaches it too.
set_row_subtitle(combo.widget(), "combo caption");
assert_eq!(
combo
.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("combo caption")
);
} }
} }
+13 -14
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,11 +483,8 @@ 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;
} }
@@ -520,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,
@@ -636,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")
} }
@@ -689,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"
@@ -751,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 }
-2
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(),
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]| {
let fp_hex = trust::hex(&fingerprint); let fp_hex = trust::hex(&fingerprint);
@@ -453,7 +452,6 @@ impl ServiceState {
paired: false, paired: false,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
} }
if let Err(e) = known.save() { if let Err(e) = known.save() {
+7 -64
View File
@@ -103,14 +103,6 @@ mod session_main {
force_software: Arc<AtomicBool>, force_software: Arc<AtomicBool>,
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>, vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
) -> SessionParams { ) -> SessionParams {
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
// here rather than passed in so every caller — a direct connect and the console's
// own launches — honors the same stored decision. `addr` is moved into the struct
// below, so read it first.
let clipboard = trust::KnownHosts::load()
.hosts
.iter()
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name` // Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
// key) to OUR gamepad service — the shells' in-process services can't reach this // key) to OUR gamepad service — the shells' in-process services can't reach this
// process. Applied per params-build (idempotent; browse re-launches included) so // process. Applied per params-build (idempotent; browse re-launches included) so
@@ -173,7 +165,6 @@ mod session_main {
// pump) pins one manually. // pump) pins one manually.
display_hdr: None, display_hdr: None,
mic_enabled: settings.mic_enabled, mic_enabled: settings.mic_enabled,
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter // The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs). // demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects. // PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
@@ -268,66 +259,19 @@ mod session_main {
) )
.init(); .init();
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
// line, discrete first) for the desktop shells' GPU picker, then exit.
if arg_flag("--list-adapters") {
return match pf_presenter::vk::list_adapters() {
Ok(names) => {
for n in names {
println!("{n}");
}
0
}
Err(e) => {
eprintln!("list-adapters: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
// same enumeration the GTK shell probes.
#[cfg(target_os = "linux")]
if arg_flag("--list-audio") {
return match pf_client_core::audio::devices() {
Ok((sinks, sources)) => {
for d in sinks {
println!("sink\t{}\t{}", d.name, d.description);
}
for d in sources {
println!("source\t{}\t{}", d.name, d.description);
}
0
}
Err(e) => {
eprintln!("list-audio: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the // Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV). // decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally. // Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
enable_radv_video_decode(); enable_radv_video_decode();
// The Settings device picks → env, unless the user already forced one by hand: // The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
// the GPU (the shells' pickers store the adapter's marketing name) for the // name) → the presenter's device selection, unless the user already forced one.
// presenter's device selection, and the audio endpoints (PipeWire node names) // Before any Vulkan call, like the RADV knob (covers --connect and --browse).
// for the playback/mic streams' `target.object`. Before any Vulkan call, like if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
// the RADV knob (covers --connect and --browse). let adapter = trust::Settings::load().adapter;
{ if !adapter.is_empty() {
let s = trust::Settings::load(); std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
for (var, value) in [
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
] {
if std::env::var_os(var).is_none() && !value.is_empty() {
std::env::set_var(var, value);
}
} }
} }
@@ -429,7 +373,6 @@ mod session_main {
v => v, v => v,
}, },
touch_mode: settings.touch_mode(), touch_mode: settings.touch_mode(),
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]| {
// This host's card carries the accent bar in the desktop client now. // This host's card carries the accent bar in the desktop client now.
+1 -17
View File
@@ -12,12 +12,6 @@ repository.workspace = true
name = "punktfunk-client" name = "punktfunk-client"
path = "src/main.rs" path = "src/main.rs"
# The couch/HTPC Start-menu entry. Its own executable because an MSIX <Application> cannot
# pass arguments to a full-trust exe — see the binary's own docs.
[[bin]]
name = "punktfunk-console"
path = "src/bin/punktfunk-console.rs"
# Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the # Everything is Windows-gated so `cargo build --workspace` stays green on Linux/macOS (the
# other native clients live in clients/linux and clients/apple); on other # other native clients live in clients/linux and clients/apple); on other
# platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux") # platforms this builds as a stub binary. Mirrors the Linux client's cfg(target_os="linux")
@@ -29,17 +23,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
@@ -57,28 +57,6 @@
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" /> <uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements> </uap:VisualElements>
</Application> </Application>
<!--
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)
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands
straight off to the session binary's controller-driven browse mode (host list,
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"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Punktfunk Console"
Description="Controller-driven couch interface for TVs and HTPCs"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
</uap:VisualElements>
</Application>
</Applications> </Applications>
<Capabilities> <Capabilities>
+1 -1
View File
@@ -72,7 +72,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layout 'Assets') | Out-Nul
# session client the shell spawns for every stream (sibling resolution — see clients/windows/ # session client the shell spawns for every stream (sibling resolution — see clients/windows/
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session # src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
# adds no DLLs of its own. # adds no DLLs of its own.
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri') $required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
foreach ($f in $required) { foreach ($f in $required) {
$src = Join-Path $TargetDir $f $src = Join-Path $TargetDir $f
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" } if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
+7 -25
View File
@@ -36,9 +36,7 @@ pub(crate) fn initiate_waking(
set_screen: &AsyncSetState<Screen>, set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>, set_status: &AsyncSetState<String>,
) { ) {
if ctx.settings.lock().unwrap().auto_wake {
crate::wol::wake(&target.mac, target.addr.parse().ok()); crate::wol::wake(&target.mac, target.addr.parse().ok());
}
initiate_opts(ctx, target, set_screen, set_status, true) initiate_opts(ctx, target, set_screen, set_status, true)
} }
@@ -274,7 +272,6 @@ fn connect_spawn(
paired: persist_paired, paired: persist_paired,
last_used: None, last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); let _ = k.save();
} }
@@ -294,13 +291,9 @@ fn connect_spawn(
*shared.target.lock().unwrap() = target.clone(); *shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair); ss.call(Screen::Pair);
} }
Some((_, false)) Some((_, false)) if wake_on_fail => {
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
{
// The dial-first attempt to a non-advertising host failed — it // The dial-first attempt to a non-advertising host failed — it
// may genuinely be asleep. NOW wake and wait. Skipped entirely // may genuinely be asleep. NOW wake and wait.
// when auto-wake is off: the wait is only worth showing if we
// are actually sending magic packets to end it.
wake_and_connect(&ctx2, target.clone(), &ss, &st); wake_and_connect(&ctx2, target.clone(), &ss, &st);
} }
Some((msg, false)) => { Some((msg, false)) => {
@@ -328,12 +321,9 @@ fn connect_spawn(
/// PAIRED host in the session window. The shell yields exactly like a stream — hidden on /// PAIRED host in the session window. The shell yields exactly like a stream — hidden on
/// the library window's `ready`, restored when the child exits (launched titles stream /// the library window's `ready`, restored when the child exits (launched titles stream
/// in that same window, so the whole couch round-trip happens without the shell). /// in that same window, so the whole couch round-trip happens without the shell).
/// `target = None` opens the console's own host view (discovery, pairing, settings) — the
/// couch entry point that isn't tied to one host; `Some` opens straight into that host's
/// library.
pub(crate) fn open_console( pub(crate) fn open_console(
ctx: &Arc<AppCtx>, ctx: &Arc<AppCtx>,
target: Option<Target>, target: Target,
set_screen: &AsyncSetState<Screen>, set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>, set_status: &AsyncSetState<String>,
) { ) {
@@ -341,21 +331,15 @@ pub(crate) fn open_console(
*ctx.shared.session.lock().unwrap() = child.clone(); *ctx.shared.session.lock().unwrap() = child.clone();
ctx.shared.stats_line.lock().unwrap().clear(); ctx.shared.stats_line.lock().unwrap().clear();
ctx.shared.browse.store(true, Ordering::SeqCst); ctx.shared.browse.store(true, Ordering::SeqCst);
if let Some(t) = target.clone() { *ctx.shared.target.lock().unwrap() = target.clone();
*ctx.shared.target.lock().unwrap() = t;
}
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream; let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
set_status.call(String::new()); set_status.call(String::new());
set_screen.call(Screen::Connecting); set_screen.call(Screen::Connecting);
let shared = ctx.shared.clone(); let shared = ctx.shared.clone();
let (ss, st) = (set_screen.clone(), set_status.clone()); let (ss, st) = (set_screen.clone(), set_status.clone());
let addr_port = target.as_ref().map(|t| (t.addr.clone(), t.port)); let spawned =
let spawned = crate::spawn::spawn_browse( crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
fullscreen,
child,
move |event| {
use crate::spawn::SpawnEvent; use crate::spawn::SpawnEvent;
match event { match event {
SpawnEvent::Ready => { SpawnEvent::Ready => {
@@ -373,8 +357,7 @@ pub(crate) fn open_console(
ss.call(Screen::Hosts); ss.call(Screen::Hosts);
} }
} }
}, });
);
if let Err(e) = spawned { if let Err(e) = spawned {
set_status.call(e); set_status.call(e);
set_screen.call(Screen::Hosts); set_screen.call(Screen::Hosts);
@@ -484,7 +467,6 @@ fn wake_and_connect(
paired: false, paired: false,
last_used: None, last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); let _ = k.save();
} }
+122 -184
View File
@@ -1,5 +1,5 @@
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect //! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / edit / //! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients. //! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, initiate_waking, open_console}; use super::connect::{initiate, initiate_waking, open_console};
@@ -14,12 +14,10 @@ use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. /// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect"; const MENU_CONNECT: &str = "Connect";
const MENU_LIBRARY: &str = "Browse library\u{2026}"; const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_CONSOLE: &str = "Open console UI";
const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host"; const MENU_WAKE: &str = "Wake host";
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the const MENU_RENAME: &str = "Rename\u{2026}";
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
const MENU_FORGET: &str = "Forget\u{2026}"; const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships /// Whether the console (gamepad) UI is available in this build: the session binary ships
@@ -189,114 +187,43 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.into() .into()
} }
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host /// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
/// property in one place, mirroring the Apple client's add/edit sheet — name, address, /// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host. /// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
/// Replaced a menu-item-per-property, which buried the useful entries in noise. /// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
/// /// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state /// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip /// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
/// through a re-render. fn rename_editor(
#[allow(clippy::too_many_arguments)] initial: &str,
fn edit_editor( fp: String,
fp: &str, live: HookRef<String>,
initial_name: &str, set_rename: AsyncSetState<Option<(String, String)>>,
name_draft: HookRef<String>,
addr_draft: HookRef<String>,
port_draft: HookRef<String>,
mac_draft: HookRef<String>,
clip_draft: HookRef<bool>,
set_edit: AsyncSetState<Option<(String, String)>>,
) -> Element { ) -> Element {
let commit = { let commit = {
let (fp, se) = (fp.to_string(), set_edit.clone()); let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
let (name_draft, addr_draft, port_draft, mac_draft, clip_draft) = (
name_draft.clone(),
addr_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
);
move || { move || {
let draft = live.borrow();
let name = draft.trim();
if !name.is_empty() {
let mut known = KnownHosts::load(); let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) { if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
// Each field falls back to what was stored: a cleared box means "leave it", h.name = name.to_string();
// never "erase it" — except the MAC, which is legitimately clearable.
let name = name_draft.borrow().trim().to_string();
if !name.is_empty() {
h.name = name;
}
let addr = addr_draft.borrow().trim().to_string();
if !addr.is_empty() {
h.addr = addr;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
if p != 0 {
h.port = p;
}
}
let mac = mac_draft.borrow().trim().to_string();
h.mac = if mac.is_empty() {
Vec::new()
} else {
mac.split(&[',', ' '][..])
.filter(|m| !m.trim().is_empty())
.map(|m| m.trim().to_string())
.collect()
};
h.clipboard_sync = *clip_draft.borrow();
} }
let _ = known.save(); let _ = known.save();
se.call(None); }
sr.call(None);
} }
}; };
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| { let on_changed = {
vstack(( let live = live.clone();
text_block(label) move |s: String| live.set(s)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Left),
text_box(&value)
.placeholder_text(placeholder)
.on_text_changed(move |t: String| draft.set(t)),
))
.spacing(2.0)
}; };
let (name0, addr0, port0, mac0, clip0) = (
name_draft.borrow().clone(),
addr_draft.borrow().clone(),
port_draft.borrow().clone(),
mac_draft.borrow().clone(),
*clip_draft.borrow(),
);
let _ = initial_name;
card( card(
vstack(( vstack((
field("Name", name0, "e.g. Living Room", name_draft), text_box(initial)
field("Address", addr0, "IP or hostname", addr_draft), .placeholder_text("Host name")
field("Port", port0, "9777", port_draft), .on_text_changed(on_changed),
field(
"MAC (Wake-on-LAN)",
mac0,
"auto-filled when known",
mac_draft,
),
vstack((
ToggleSwitch::new(clip0)
.header("Share clipboard with this host")
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| clip_draft.set(v)),
text_block(
"Copy on one machine, paste on the other. Off for every host until you \
turn it on here; the host must allow it too.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
hstack(( hstack((
button("Save") button("Save")
.accent() .accent()
@@ -304,7 +231,7 @@ fn edit_editor(
.on_click(commit), .on_click(commit),
button("Cancel") button("Cancel")
.subtle() .subtle()
.on_click(move || set_edit.call(None)), .on_click(move || set_rename.call(None)),
)) ))
.spacing(4.0), .spacing(4.0),
)) ))
@@ -337,41 +264,16 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let rename = props.rename.clone(); let rename = props.rename.clone();
let set_forget = &props.set_forget; let set_forget = &props.set_forget;
let set_rename = &props.set_rename; let set_rename = &props.set_rename;
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only // The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
// the target's fingerprint + initial name, so typing never round-trips through a // INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
// re-render. Every draft is re-seeded from the STORED host whenever the edit target // TARGET changes (start, cancel, or a switch to another host).
// changes (open, cancel, or switching to another host). let rename_draft = cx.use_ref(String::new());
let name_draft = cx.use_ref(String::new()); let rename_seed = cx.use_ref(Option::<String>::None);
let addr_draft = cx.use_ref(String::new());
let port_draft = cx.use_ref(String::new());
let mac_draft = cx.use_ref(String::new());
let clip_draft = cx.use_ref(false);
let edit_seed = cx.use_ref(Option::<String>::None);
{ {
let active = rename.as_ref().map(|(fp, _)| fp.clone()); let active = rename.as_ref().map(|(fp, _)| fp.clone());
if *edit_seed.borrow() != active { if *rename_seed.borrow() != active {
let stored = active.as_ref().and_then(|fp| { rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
KnownHosts::load() rename_seed.set(active);
.hosts
.into_iter()
.find(|h| &h.fp_hex == fp)
});
name_draft.set(stored.as_ref().map(|h| h.name.clone()).unwrap_or_default());
addr_draft.set(stored.as_ref().map(|h| h.addr.clone()).unwrap_or_default());
port_draft.set(
stored
.as_ref()
.map(|h| h.port.to_string())
.unwrap_or_default(),
);
mac_draft.set(
stored
.as_ref()
.map(|h| h.mac.join(", "))
.unwrap_or_default(),
);
clip_draft.set(stored.as_ref().is_some_and(|h| h.clipboard_sync));
edit_seed.set(active);
} }
} }
let hover = Hover { let hover = Hover {
@@ -412,51 +314,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.spacing(2.0) .spacing(2.0)
.grid_column(0) .grid_column(0)
.vertical_alignment(VerticalAlignment::Center), .vertical_alignment(VerticalAlignment::Center),
hstack({ hstack((
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add) header_btn("Add host", Symbol::Add).accent().on_click({
.accent()
.on_click({
let sa = set_show_add.clone(); let sa = set_show_add.clone();
move || sa.call(true) move || sa.call(true)
}) }),
.into()]; header_btn("Shortcuts", Symbol::Keyboard).on_click({
// The couch UI's front door, beside the other page actions. Absent on ARM64,
// where the session binary ships without its Skia console.
if CONSOLE_UI_AVAILABLE {
actions.push(
header_btn("Console UI", Symbol::Play)
.tooltip(
"The controller-driven couch interface \u{2014} host list, \
pairing and libraries, launching streams in the same window.",
)
.on_click({
let (c, ss, st) =
(ctx.clone(), set_screen.clone(), set_status.clone());
// No target: the console opens its OWN host view rather than
// one host's library — the couch counterpart of this page.
move || open_console(&c, None, &ss, &st)
})
.into(),
);
}
actions.push(
header_btn("Shortcuts", Symbol::Keyboard)
.on_click({
let ss = set_screen.clone(); let ss = set_screen.clone();
move || ss.call(Screen::Help) move || ss.call(Screen::Help)
}) }),
.into(), header_btn("Settings", Symbol::Setting).on_click({
);
actions.push(
header_btn("Settings", Symbol::Setting)
.on_click({
let ss = set_screen.clone(); let ss = set_screen.clone();
move || ss.call(Screen::Settings) move || ss.call(Screen::Settings)
}) }),
.into(), ))
);
actions
})
.spacing(8.0) .spacing(8.0)
.grid_column(1) .grid_column(1)
.vertical_alignment(VerticalAlignment::Center), .vertical_alignment(VerticalAlignment::Center),
@@ -476,23 +347,84 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
); );
} }
// A controller is connected and a paired host is REACHABLE (advertising or probed —
// an offline host would just open the console onto an error scene): offer the couch
// experience — the console (gamepad) UI on the most recently used such host.
if CONSOLE_UI_AVAILABLE && props.pads > 0 {
let reachable = |k: &&crate::trust::KnownHost| {
hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false)
};
if let Some(k) = known
.hosts
.iter()
.filter(|h| h.paired)
.filter(reachable)
.max_by_key(|h| h.last_used.unwrap_or(0))
{
let target = Target {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
};
let svc = props.svc.clone();
body.push(
card(
grid((
vstack((
text_block("Controller detected").font_size(14.0).semibold(),
text_block(format!(
"Browse {}\u{2019}s game library with the gamepad \u{2014} \
launches stream in the same window.",
k.name
))
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Open console UI")
.accent()
.icon(Symbol::Play)
.on_click(move || {
open_console(
&svc.ctx,
target.clone(),
&svc.set_screen,
&svc.set_status,
)
})
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(12.0, 0.0, 0.0, 0.0)),
))
.columns([GridLength::Star(1.0), GridLength::Auto]),
)
.into(),
);
}
}
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also // Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
// being advertised right now shows as Online (and is deduped out of the discovery section). // being advertised right now shows as Online (and is deduped out of the discovery section).
if !known.hosts.is_empty() { if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS")); body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new(); let mut tiles: Vec<Element> = Vec::new();
for k in &known.hosts { for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly. // Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) { if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
let (fp, initial) = rename.clone().unwrap(); let (fp, initial) = rename.clone().unwrap();
tiles.push(edit_editor( tiles.push(rename_editor(
&fp,
&initial, &initial,
name_draft.clone(), fp,
addr_draft.clone(), rename_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
set_rename.clone(), set_rename.clone(),
)); ));
continue; continue;
@@ -539,12 +471,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if library_enabled && k.paired { if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY)); items.push(menu_item(MENU_LIBRARY));
} }
if CONSOLE_UI_AVAILABLE && k.paired {
items.push(menu_item(MENU_CONSOLE));
}
items.push(menu_item(MENU_SPEED)); items.push(menu_item(MENU_SPEED));
// Offer an explicit wake only when the host is offline and we have a MAC. // Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake { if can_wake {
items.push(menu_item(MENU_WAKE)); items.push(menu_item(MENU_WAKE));
} }
items.push(menu_item(MENU_EDIT)); items.push(menu_item(MENU_RENAME));
items.push(menu_separator()); items.push(menu_separator());
items.push(menu_item(MENU_FORGET)); items.push(menu_item(MENU_FORGET));
items items
@@ -558,6 +493,9 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
super::library::start_fetch(&svc.ctx, &svc.set_library); super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library); svc.set_screen.call(Screen::Library);
} }
MENU_CONSOLE => {
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()), MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_SPEED => { MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone(); *svc.ctx.shared.target.lock().unwrap() = target.clone();
@@ -569,7 +507,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
svc.set_speed.call(SpeedState::Running); svc.set_speed.call(SpeedState::Running);
svc.set_screen.call(Screen::SpeedTest); svc.set_screen.call(Screen::SpeedTest);
} }
MENU_EDIT => sr.call(Some((fp.clone(), name.clone()))), MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))), MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
_ => {} _ => {}
}) })
+1 -2
View File
@@ -241,8 +241,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page. // reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
let (hover, set_hover) = cx.use_async_state(Option::<String>::None); let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
// Which Settings section the NavigationView shows (persists across visits this run). // Which Settings section the NavigationView shows (persists across visits this run).
// Opens on General — the first sidebar item, matching the Apple client's landing category. let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
// Connected-controller count, mirrored from the gamepad service by a poll thread // Connected-controller count, mirrored from the gamepad service by a poll thread
// (thread-driven state must be root state — see the module docs). Drives the hosts // (thread-driven state must be root state — see the module docs). Drives the hosts
// page's "Open console UI" hint; the compare in `call` makes the steady state free. // page's "Open console UI" hint; the compare in `call` makes the steady state free.
-1
View File
@@ -59,7 +59,6 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
paired: true, paired: true,
last_used: None, last_used: None,
mac: target3.mac.clone(), mac: target3.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st); connect(&ctx3, &target3, Some(fp), &ss, &st);
+120 -315
View File
@@ -1,15 +1,5 @@
//! The settings screen. Every control writes straight back to the persisted [`Settings`] //! The settings screen. Every control writes straight back to the persisted [`Settings`]
//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders. //! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders.
//!
//! **Structure mirrors the Apple client's 2026-07 settings revamp** (its
//! `SettingsCategory` + `SettingsView+Sections.swift`), so the two desktop clients read the
//! same way: General = session/app behavior, Display = everything about the picture,
//! Input = touch/keyboard/mouse, Audio, Controllers, About. Each field carries its
//! explanation DIRECTLY under it ([`described`]) rather than only on hover — the same move
//! Apple made, for the same reason (guidance nobody hovers for is guidance nobody reads).
//! Wording is shared verbatim wherever the setting means the same thing on both platforms;
//! where the BEHAVIOR differs the text is deliberately Windows-specific (the forwarded-
//! controller picker especially: Apple forwards one pad, this client forwards them all).
use super::style::*; use super::style::*;
use super::{AppCtx, Screen}; use super::{AppCtx, Screen};
@@ -48,8 +38,7 @@ fn render_scale_label(scale: f64) -> String {
// Automatic — which is exactly how the session's decoder chain reads that value. // Automatic — which is exactly how the session's decoder chain reads that value.
const DECODERS: &[(&str, &str)] = &[ const DECODERS: &[(&str, &str)] = &[
("auto", "Automatic (GPU, fall back to CPU)"), ("auto", "Automatic (GPU, fall back to CPU)"),
("vulkan", "Hardware (Vulkan Video)"), ("vulkan", "Hardware (GPU / Vulkan Video)"),
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
("software", "Software (CPU)"), ("software", "Software (CPU)"),
]; ];
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can /// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
@@ -148,63 +137,11 @@ fn setting_toggle(
}) })
} }
/// One field: the control with its explanation directly underneath (Apple's `described`). /// A settings card: just the controls. No heading (the section title is the NavigationView
/// /// header) and no description paragraph — per-control guidance is a `.tooltip(...)` on the
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in /// control itself (a paragraph in the card reads as the first control's label).
/// hover tooltips because a paragraph *above* a control reads as that control's label — true, fn settings_card(controls: Vec<Element>) -> Element {
/// but a caption under it reads as a caption, which is how every Windows Settings page and card(vstack(controls).spacing(10.0)).into()
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
/// full-width caption runs into the control column and the whole cell reads as one block.
fn described(control: impl Into<Element>, caption: &str) -> Element {
vstack((
control.into(),
text_block(caption)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.max_width(420.0)
// Stretch (the TextBlock default) CENTRES a MaxWidth-capped block in the leftover
// width — the caption must be pinned left or it drifts away from its control.
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(5.0)
.into()
}
/// A settings sub-section heading. Deliberately NOT the shared [`section`] helper: that one
/// carries a 2px left inset (fine over the hosts/licenses lists it was written for), which
/// here left every heading hanging one nudge right of the card edge below it. Flush left, so
/// heading and card share one line.
fn group_heading(label: &str) -> Element {
text_block(label)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 14.0, 0.0, 2.0))
.into()
}
/// One settings group: an optional sub-section label, a card of fields, and an optional
/// form-level note under it (Apple's Section header/footer). Groups stack down the page.
fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Vec<Element> {
let mut out = Vec::with_capacity(3);
if let Some(h) = header {
out.push(group_heading(h));
}
out.push(card(vstack(fields).spacing(14.0)).into());
if let Some(f) = footer {
out.push(
text_block(f)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 6.0, 0.0, 0.0))
.into(),
);
}
out
} }
/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) — /// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) —
@@ -249,7 +186,12 @@ pub(crate) fn settings_page(
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| { let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
s.match_window = i == 1; s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] }; (s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
}); })
.tooltip(
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
follows the stream window, including mid-stream resizes.",
);
let (hz_names, hz_i) = { let (hz_names, hz_i) = {
let names: Vec<String> = REFRESH let names: Vec<String> = REFRESH
.iter() .iter()
@@ -266,7 +208,8 @@ pub(crate) fn settings_page(
}; };
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| { let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
s.refresh_hz = REFRESH[i]; s.refresh_hz = REFRESH[i];
}); })
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
let (scale_names, scale_i) = { let (scale_names, scale_i) = {
let names: Vec<String> = RENDER_SCALES let names: Vec<String> = RENDER_SCALES
.iter() .iter()
@@ -280,26 +223,36 @@ pub(crate) fn settings_page(
}; };
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| { let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
s.render_scale = RENDER_SCALES[i]; s.render_scale = RENDER_SCALES[i];
}); })
.tooltip(
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
);
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor); let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| { let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string(); s.compositor = COMPOSITORS[i].0.to_string();
}); })
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| { .tooltip(
s.auto_wake = on "Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
}); choice is unavailable.",
);
let fullscreen_toggle = setting_toggle( let fullscreen_toggle = setting_toggle(
ctx, ctx,
"Start streams fullscreen", "Start streams fullscreen",
s.fullscreen_on_stream, s.fullscreen_on_stream,
|s, on| s.fullscreen_on_stream = on, |s, on| s.fullscreen_on_stream = on,
); )
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
// --- Video ----------------------------------------------------------------------------- // --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder); let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| { let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string(); s.decoder = DECODERS[i].0.to_string();
}); })
.tooltip(
"Hardware decode (Vulkan Video) is far lighter than software \u{2014} keep it on \
Automatic unless debugging.",
);
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents. // GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
// Stored as the adapter description; empty = automatic (the window's monitor's adapter). // Stored as the adapter description; empty = automatic (the window's monitor's adapter).
let gpus = crate::gpu::adapter_names(); let gpus = crate::gpu::adapter_names();
@@ -318,11 +271,18 @@ pub(crate) fn settings_page(
gpus[i - 1].clone() gpus[i - 1].clone()
}; };
}) })
.tooltip(
"Which adapter decodes and presents the stream. Applies to the next stream; \
Automatic uses the GPU driving this window's display.",
)
}); });
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec); let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| { let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string(); s.codec = CODECS[i].0.to_string();
}); })
.tooltip(
"A soft preference \u{2014} the host falls back to the best codec both sides support. PyroWave is the low-latency wavelet codec for a WIRED link: it trades bitrate (hundreds of Mb/s) for near-zero decode time, so it needs gigabit Ethernet.",
);
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation // Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
// round-trips exactly. // round-trips exactly.
let bitrate_box = { let bitrate_box = {
@@ -335,10 +295,18 @@ pub(crate) fn settings_page(
s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32; s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32;
s.save(); s.save();
}) })
.tooltip(
"0 lets the host decide. Run a per-host speed test from the host list for a \
recommendation.",
)
}; };
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| { let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
s.hdr_enabled = on s.hdr_enabled = on
}); })
.tooltip(
"Advertise 10-bit HDR10 so the host upgrades HDR content. Needs a display in HDR mode; \
SDR content is unaffected.",
);
// --- Input ----------------------------------------------------------------------------- // --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad; // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -383,44 +351,60 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default(); s.forward_pad = key.unwrap_or_default();
s.save(); s.save();
}) })
.tooltip(
"Every connected controller is forwarded, each as its own player. Pick one \
to force single-player \u{2014} only it reaches the host.",
)
}; };
let (pad_names, pad_i) = presets(GAMEPADS, |v| { let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad) GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
}); });
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| { let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string(); s.gamepad = GAMEPADS[i].0.to_string();
}); })
.tooltip(
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
controller.",
);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode); let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
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 invert_scroll_toggle = .tooltip(
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| { "How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
s.invert_scroll = on pointer jumps to your finger, Touch passthrough sends real touches.",
}); );
let shortcuts_toggle = setting_toggle( let shortcuts_toggle = setting_toggle(
ctx, ctx,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})", "Capture system shortcuts (Alt+Tab, Win, \u{2026})",
s.inhibit_shortcuts, s.inhibit_shortcuts,
|s, on| s.inhibit_shortcuts = on, |s, on| s.inhibit_shortcuts = on,
); )
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
// --- Audio ----------------------------------------------------------------------------- // --- Audio -----------------------------------------------------------------------------
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels); let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| { let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
s.audio_channels = AUDIO_CHANNELS[i].0; s.audio_channels = AUDIO_CHANNELS[i].0;
}); })
.tooltip("The host downmixes if its output has fewer channels.");
let mic_toggle = setting_toggle( let mic_toggle = setting_toggle(
ctx, ctx,
"Stream microphone to the host", "Stream microphone to the host",
s.mic_enabled, s.mic_enabled,
|s, on| s.mic_enabled = on, |s, on| s.mic_enabled = on,
); )
.tooltip("Sends the default microphone to the host's virtual mic source.");
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity()); let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| { let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0); s.set_stats_verbosity(STATS_TIERS[i].0);
}); })
.tooltip(
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
);
let licenses_button = { let licenses_button = {
let ss = set_screen.clone(); let ss = set_screen.clone();
@@ -431,6 +415,10 @@ pub(crate) fn settings_page(
"Show game library (experimental)", "Show game library (experimental)",
s.library_enabled, s.library_enabled,
|s, on| s.library_enabled = on, |s, on| s.library_enabled = on,
)
.tooltip(
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
launches in the stream. Mirrors the Apple client's toggle.",
); );
// App identity + version at the top of the About card (the WinUI Settings convention; the About // App identity + version at the top of the About card (the WinUI Settings convention; the About
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked // screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
@@ -443,227 +431,70 @@ pub(crate) fn settings_page(
)) ))
.spacing(2.0); .spacing(2.0);
// The selected section's content, grouped exactly like the Apple client's categories // The selected section's content — per-control guidance lives on hover tooltips, so the
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under // card is just the controls.
// it; the only form-level notes are the "applies from the next session" footers, matching let (title, card): (&str, Element) = match section {
// Apple's decision to keep exactly one of those per affected category. "video" => (
let (title, groups): (&str, Vec<Element>) = match section { "Video",
"display" => { settings_card({
let mut out = group( let mut controls: Vec<Element> = vec![decoder_combo.into()];
Some("Resolution"),
vec![
described(
res_combo,
"The host drives a real virtual output at exactly this size \u{2014} true \
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
(1:1) through every resize.",
),
described(
hz_combo,
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
connect.",
),
],
None,
);
out.extend(group(
Some("Quality"),
vec![
described(
scale_combo,
"Above native supersamples for sharpness; below renders lighter on the \
host and the link. This device resamples the result to the window.",
),
described(
bitrate_box,
"0 lets the host decide (its default, clamped to what it supports). A \
host card\u{2019}s context menu has a network speed test.",
),
described(
codec_combo,
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
gigabit Ethernet.",
),
described(
hdr_toggle,
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
),
],
None,
));
out.extend(group(
Some("Decoding"),
{
let mut fields = vec![described(
decoder_combo,
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
11 on Intel, Vulkan Video on NVIDIA and AMD \u{2014} and falls back to \
the CPU. Change it only when debugging.",
)];
if let Some(c) = gpu_combo { if let Some(c) = gpu_combo {
fields.push(described( controls.push(c.into());
c,
"Which adapter decodes and presents the stream. Automatic uses the \
GPU driving this window\u{2019}s display.",
));
} }
fields controls.extend([
}, codec_combo.into(),
None, bitrate_box.into(),
)); hdr_toggle.into(),
out.extend(group( hud_combo.into(),
Some("Host output"), ]);
vec![described( controls
comp_combo, }),
"The backend the host uses for its virtual output (Linux hosts only). A \
specific choice falls back to auto-detection when that backend \
isn\u{2019}t available.",
)],
// The one form-level note, exactly as on Apple.
Some("Display changes apply from the next session."),
));
("Display", out)
}
"input" => {
let mut out = group(
Some("Touch & pointer"),
vec![described(
touch_combo,
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
you touch, Touch passthrough sends real multi-touch through.",
)],
None,
);
out.extend(group(
Some("Keyboard & mouse"),
vec![
described(
shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \
has input captured. Off, they act on this machine instead.",
),
described(
invert_scroll_toggle,
"Reverses the wheel and trackpad scroll direction sent to the host.",
),
],
None,
));
("Input", out)
}
"controllers" => (
"Controllers",
group(
None,
vec![
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
described(
forward_combo,
"Every connected controller is forwarded, each as its own player. Pick \
one to force single-player \u{2014} only it reaches the host.",
),
described(
pad_combo,
"The virtual pad created on the host. Automatic matches your controller \
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
),
],
Some("Applies from the next session."),
), ),
"input" => (
"Input",
settings_card(vec![
forward_combo.into(),
pad_combo.into(),
touch_combo.into(),
shortcuts_toggle.into(),
]),
), ),
"audio" => ( "audio" => (
"Audio", "Audio",
group( settings_card(vec![channels_combo.into(), mic_toggle.into()]),
None,
vec![
described(
channels_combo,
"The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.",
),
described(
mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
),
],
Some("Applies from the next session."),
),
), ),
"about" => ( "about" => (
"About", "About",
group( settings_card(vec![
None, about_identity.into(),
vec![about_identity.into(), licenses_button.into()], library_toggle.into(),
None, licenses_button.into(),
]),
), ),
_ => (
"Display",
settings_card(vec![
res_combo.into(),
hz_combo.into(),
scale_combo.into(),
fullscreen_toggle.into(),
comp_combo.into(),
]),
), ),
// "general" and anything unrecognized.
_ => {
let mut out = group(
Some("Session"),
vec![
described(
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
live.",
),
described(
auto_wake_toggle,
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
waits for it to boot. Turn off if hosts behind a VPN look offline when \
they aren\u{2019}t.",
),
],
None,
);
out.extend(group(
Some("Statistics"),
vec![described(
hud_combo,
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
tiers any time.",
)],
None,
));
out.extend(group(
Some("Library"),
vec![described(
library_toggle,
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
their Steam and custom games and launch one directly. No extra host setup.",
)],
None,
));
("General", out)
}
}; };
// The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card // The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card
// as content, the NavigationView's own back arrow returning to the host list. Auto display // as content, the NavigationView's own back arrow returning to the host list. Auto display
// mode collapses the pane on a narrow window, exactly like Windows Settings. // mode collapses the pane on a narrow window, exactly like Windows Settings.
// Category order mirrors the Apple client's sidebar exactly.
let items = vec![ let items = vec![
NavViewItem::new("General")
.tag("general")
.icon(Symbol::Setting),
NavViewItem::new("Display") NavViewItem::new("Display")
.tag("display") .tag("display")
.icon(Symbol::FullScreen), .icon(Symbol::FullScreen),
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
NavViewItem::new("Input") NavViewItem::new("Input")
.tag("input") .tag("input")
.icon(Symbol::Keyboard), .icon(Symbol::Keyboard),
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume), NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
NavViewItem::new("Controllers")
.tag("controllers")
.icon(Symbol::Play),
NavViewItem::new("About").tag("about").icon(Symbol::Help), NavViewItem::new("About").tag("about").icon(Symbol::Help),
]; ];
// The card is KEYED by section so switching panes REMOUNTS it instead of diffing one // The card is KEYED by section so switching panes REMOUNTS it instead of diffing one
@@ -674,38 +505,12 @@ pub(crate) fn settings_page(
// //
// The content column (not the NavigationView — the sidebar must stay put) carries the // The content column (not the NavigationView — the sidebar must stay put) carries the
// section-switch entrance: fade + slide-up from the root-driven tween. // section-switch entrance: fade + slide-up from the root-driven tween.
// No max-width cap here (unlike the other pages): the NavigationView already spends the let content = page_wide(vec![card.with_key(section)])
// left third on its pane, so a 640-wide column left the cards as a narrow ribbon.
// The category title is rendered HERE, not via NavigationView's Header: that header's
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
// sat noticeably right of the cards under it. In the content column it shares the cards'
// left edge by construction.
let titled: Vec<Element> = std::iter::once(
text_block(title)
.font_size(28.0)
.semibold()
.horizontal_alignment(HorizontalAlignment::Left)
.margin(edges(0.0, 0.0, 0.0, 6.0))
.into(),
)
.chain(groups)
.collect();
// The keyed column MUST sit inside a panel's child list, not directly under the
// scroll_view: `ScrollView::children()` is `Children::PositionalSingle`, which
// reconciles its one child POSITIONALLY and ignores keys outright. Keyed straight onto
// the scroll_view's child, the section switch silently diffs one section's controls into
// another's — which re-sets each reused ComboBox's items (clearing WinUI's selection)
// but skips `selected_index` whenever the two sections' values compare equal, so the
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
// remounts the whole column and every prop is applied fresh.
let content = scroll_view(
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
)
.opacity(progress) .opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0)); .margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
NavigationView::new(items, content) NavigationView::new(items, content)
.pane_title("Settings") .pane_title("Settings")
.header(title)
.selected_tag(section) .selected_tag(section)
.on_selection_changed({ .on_selection_changed({
let ss = set_section.clone(); let ss = set_section.clone();
@@ -1,38 +0,0 @@
//! `punktfunk-console.exe` — the couch/HTPC entry point.
//!
//! Exists because an MSIX `<Application>` cannot pass ARGUMENTS to a full-trust executable:
//! a second Start-menu tile therefore cannot simply be "punktfunk-client.exe --console", it
//! needs its own executable. This is that executable, and it is deliberately nothing but a
//! hand-off — it starts the session binary's `--browse` mode (the complete controller-driven
//! client: host list, discovery, PIN pairing, settings, Wake-on-LAN, library) fullscreen and
//! mirrors its exit code, so whatever supervises this process sees the real result.
//!
//! `--windowed` keeps it in a window; everything else is the session binary's own business.
// No console window: this is launched from a Start-menu tile / shortcut, and a flashing
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(windows)]
fn main() {
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
.map(|e| e.with_file_name("punktfunk-session.exe"))
.filter(|p| p.exists())
.unwrap_or_else(|| "punktfunk-session".into());
let mut cmd = std::process::Command::new(session);
cmd.arg("--browse");
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
}
/// The workspace builds on Linux/macOS too; there is nothing to launch there.
#[cfg(not(windows))]
fn main() {}
-21
View File
@@ -76,27 +76,6 @@ fn main() {
return; return;
} }
// `--console`: go straight to the gamepad/couch UI, skipping the WinUI shell entirely —
// the HTPC entry point (a Start-menu tile, a Steam shortcut, a startup item). The session
// binary's bare `--browse` IS a complete standalone client: host list, discovery, PIN
// pairing, settings and Wake-on-LAN, all controller-driven. We just exec it and mirror
// its exit code, so anything supervising this process sees the real result.
if flag("--console") {
let mut cmd = std::process::Command::new(spawn::session_binary());
cmd.arg("--browse");
// A couch UI is fullscreen unless explicitly told otherwise.
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
std::process::exit(1);
}
}
}
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing. // Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI // Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
// call (build.rs stages the bootstrap DLL via windows-reactor-setup). // call (build.rs stages the bootstrap DLL via windows-reactor-setup).
+6 -11
View File
@@ -126,26 +126,21 @@ pub(crate) fn spawn_session(
/// The same stdout contract as a connect (`--json-status`): `ready` when the library /// The same stdout contract as a connect (`--json-status`): `ready` when the library
/// window presents, `error` on a failed start, EOF on quit. /// window presents, `error` on a failed start, EOF on quit.
pub(crate) fn spawn_browse( pub(crate) fn spawn_browse(
target: Option<(&str, u16)>, addr: &str,
port: u16,
fullscreen: bool, fullscreen: bool,
slot: SessionChild, slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static, on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> { ) -> Result<(), String> {
let mut cmd = Command::new(session_binary()); let mut cmd = Command::new(session_binary());
cmd.arg("--browse"); cmd.arg("--browse")
// A target opens straight into that host's library; bare `--browse` opens the console's .arg(format!("{addr}:{port}"))
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of .arg("--json-status");
// the shell's hosts page.
if let Some((addr, port)) = target {
cmd.arg(format!("{addr}:{port}"));
}
cmd.arg("--json-status");
if fullscreen { if fullscreen {
cmd.arg("--fullscreen"); cmd.arg("--fullscreen");
} }
add_window_pos(&mut cmd); add_window_pos(&mut cmd);
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}")); spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
spawn_with(cmd, &label, slot, on_event)
} }
/// Hand the shell window's position to the child (`--window-pos`) so the session window /// Hand the shell window's position to the child (`--window-pos`) so the session window
+1 -22
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
@@ -254,19 +244,8 @@ pub struct ZeroCopyPolicy {
/// The resolved backend produces GPU-resident frames (everything but the software encoder) — /// The resolved backend produces GPU-resident frames (everything but the software encoder) —
/// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`). /// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`).
pub backend_is_gpu: bool, pub backend_is_gpu: bool,
/// THIS session encodes PyroWave: the frames' consumer is the wavelet encoder's own Vulkan
/// device, which imports raw dmabufs on ANY vendor — so the capturer takes the raw-dmabuf
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
pub pyrowave_session: bool,
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
/// Video backend on an H265/AV1 session — resolved by the host facade via
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
pub native_nv12_session: bool,
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc, /// 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 encoder pref is `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.
pub pyrowave_modifiers: Vec<u64>, pub pyrowave_modifiers: Vec<u64>,
} }
+113 -252
View File
@@ -255,11 +255,9 @@ fn spawn_pipewire(
want_hdr want_hdr
}; };
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI // Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
// backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer // backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf // negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
// passthrough offer. let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi;
let vaapi_dmabuf =
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
let join = thread::Builder::new() let join = thread::Builder::new()
.name("punktfunk-pipewire".into()) .name("punktfunk-pipewire".into())
.spawn(move || { .spawn(move || {
@@ -299,11 +297,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 +415,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 +425,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 +438,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 +450,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 +822,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 +987,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 +1001,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 +1030,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 +1308,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 +1345,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 +1367,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 +1576,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 +1585,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 +1614,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 +1627,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;
@@ -2044,18 +1945,16 @@ mod pipewire {
// Build the GPU importer up front — normally the ISOLATED worker process // Build the GPU importer up front — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's // (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path // dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
// (we simply won't request dmabuf below). Skipped entirely when the frames go to the // (we simply won't request dmabuf below). Skipped entirely when the encode backend is
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave // VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there // would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can // succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU // repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
// stack must not crash-loop).
let backend_is_vaapi = policy.backend_is_vaapi; let backend_is_vaapi = policy.backend_is_vaapi;
let raw_passthrough = backend_is_vaapi || policy.pyrowave_session;
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8, // HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path // which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough. // (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
let mut importer = if zerocopy && !raw_passthrough && !want_hdr { let mut importer = if zerocopy && !backend_is_vaapi && !want_hdr {
if pf_zerocopy::gpu_import_disabled() { if pf_zerocopy::gpu_import_disabled() {
tracing::warn!( tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path" "zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
@@ -2081,33 +1980,9 @@ mod pipewire {
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render // host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
// race). // race).
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1"); let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames' // VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a // the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi;
// dmabuf straight to the encoder.
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:
@@ -2123,9 +1998,8 @@ mod pipewire {
// advertisement with every modifier its device samples from, so compositors that // advertisement with every modifier its device samples from, so compositors that
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers // never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when // were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is // the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its // calls back into `encode` and needs no feature gate of its own (the emptiness check gates it).
// own (the emptiness check gates it).
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() { if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
for &m in &policy.pyrowave_modifiers { for &m in &policy.pyrowave_modifiers {
if !modifiers.contains(&m) { if !modifiers.contains(&m) {
@@ -2145,13 +2019,11 @@ mod pipewire {
); );
} else if zerocopy && !want_dmabuf { } else if zerocopy && !want_dmabuf {
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 {
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 {
tracing::info!( tracing::info!(
count = modifiers.len(), count = modifiers.len(),
sample = ?&modifiers[..modifiers.len().min(6)], sample = ?&modifiers[..modifiers.len().min(6)],
@@ -2285,37 +2157,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 +2265,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 +2361,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,
-4
View File
@@ -64,10 +64,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the # IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
# method itself is feature-gated behind this. # method itself is feature-gated behind this.
"Win32_Security", "Win32_Security",
# The OS-clipboard bridge (clipboard.rs): Open/Get/SetClipboardData + the sequence
# number, and the GlobalAlloc block the clipboard takes ownership of.
"Win32_System_DataExchange",
"Win32_System_Memory",
] } ] }
[features] [features]
+13 -101
View File
@@ -20,83 +20,6 @@ const MIC_FRAME: usize = 960;
struct Terminate; struct Terminate;
/// A selectable PipeWire endpoint for the settings pickers.
#[derive(Clone, Debug)]
pub struct AudioDevice {
/// `node.name` — the stable key the streams target via `target.object`.
pub name: String,
/// `node.description` — the human label the picker shows.
pub description: String,
}
/// Enumerate audio endpoints: `(sinks, sources)`. One registry roundtrip on a private
/// mainloop (a few ms against a live PipeWire); no daemon errors out and the caller
/// simply shows no pickers.
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
use pipewire as pw;
use std::cell::RefCell;
use std::rc::Rc;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_rc(None)
.context("pw connect (is PipeWire running in this session?)")?;
let registry = core.get_registry_rc().context("pw registry")?;
let found: Rc<RefCell<(Vec<AudioDevice>, Vec<AudioDevice>)>> = Rc::default();
let _reg_listener = registry
.add_listener_local()
.global({
let found = found.clone();
move |g| {
let Some(props) = g.props else { return };
let sink = match props.get("media.class") {
Some("Audio/Sink") => true,
Some("Audio/Source") => false,
_ => return,
};
let Some(name) = props.get("node.name") else {
return;
};
let description = props
.get("node.description")
.or_else(|| props.get("node.nick"))
.unwrap_or(name)
.to_string();
let dev = AudioDevice {
name: name.to_string(),
description,
};
let mut f = found.borrow_mut();
if sink { &mut f.0 } else { &mut f.1 }.push(dev);
}
})
.register();
// The registry replays existing globals asynchronously; one core sync marks the
// point they've all been delivered — quit the loop there.
let pending = core.sync(0).context("pw sync")?;
let _core_listener = core
.add_listener_local()
.done({
let mainloop = mainloop.clone();
move |_, seq| {
if seq == pending {
mainloop.quit();
}
}
})
.register();
mainloop.run();
let result = found.borrow().clone();
Ok(result)
}
pub struct AudioPlayer { pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>, pcm_tx: SyncSender<Vec<f32>>,
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half /// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
@@ -195,7 +118,10 @@ fn pw_thread(
move |_| mainloop.quit() move |_| mainloop.quit()
}); });
let mut props = properties! { let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-client",
properties! {
*pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback", *pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game", *pw::keys::MEDIA_ROLE => "Game",
@@ -203,18 +129,9 @@ fn pw_thread(
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream", *pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small. // ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
*pw::keys::NODE_LATENCY => "240/48000", *pw::keys::NODE_LATENCY => "240/48000",
}; },
// The Settings speaker pick (session main maps `Settings::speaker_device` here); )
// unset/empty = PipeWire's default routing. .context("pw Stream")?;
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SINK") {
if !target.is_empty() {
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
// libpipewire than we require; the wire name is stable.
props.insert("target.object", target);
}
}
let stream =
pw::stream::StreamBox::new(&core, "punktfunk-client", props).context("pw Stream")?;
let ud = PlayerData { let ud = PlayerData {
rx: pcm_rx, rx: pcm_rx,
@@ -399,22 +316,17 @@ fn mic_thread(
move |_| mainloop.quit() move |_| mainloop.quit()
}); });
let mut props = properties! { let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-mic-capture",
properties! {
*pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication", *pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture", *pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone", *pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
}; },
// The Settings microphone pick (`Settings::mic_device` via session main). )
if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") {
if !target.is_empty() {
// Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer
// libpipewire than we require; the wire name is stable.
props.insert("target.object", target);
}
}
let stream = pw::stream::StreamBox::new(&core, "punktfunk-mic-capture", props)
.context("pw mic Stream")?; .context("pw mic Stream")?;
let ud = MicData { let ud = MicData {
-421
View File
@@ -1,421 +0,0 @@
//! OS-clipboard bridge for the spawned session client (`design/clipboard-and-file-transfer.md`
//! §5). The protocol half already exists in `punktfunk_core::clipboard` — the per-session task
//! that runs fetch streams — and `NativeClient` exposes it as `clip_control` / `clip_offer` /
//! `clip_fetch` / `clip_serve` / `next_clip`. What was missing on Windows is exactly what §5.2
//! writes in Swift for macOS: the code that talks to the actual pasteboard. This is that half.
//!
//! Shape (one thread, owned by the session pump):
//!
//! * **Local → remote** stays lazy by construction. A poll of `GetClipboardSequenceNumber`
//! spots a local copy, we announce the FORMAT LIST (`clip_offer`) and nothing else; the
//! bytes are read only if the host actually pastes and sends a `FetchRequest`.
//! * **Remote → local** is EAGER in this first cut, and that is a deliberate deviation from
//! §5.2's promise-based apply. macOS gets laziness free from `NSPasteboardItemDataProvider`;
//! the Windows equivalent is delayed rendering (`SetClipboardData(fmt, NULL)` answered on
//! `WM_RENDERFORMAT`), which needs a clipboard-owning window running its own message pump —
//! a bigger piece than this. So we fetch on the offer and place real bytes, under
//! [`EAGER_FETCH_CAP`] so a huge host-side copy can't pull megabytes nobody pastes. Text is
//! tiny and always crosses; a large image simply isn't mirrored until delayed rendering lands.
//! * **Echo suppression** is §3.4's Windows rule verbatim: record the clipboard sequence
//! number right after our own `SetClipboardData` and ignore exactly that change, or every
//! copy ping-pongs between the two machines forever.
//!
//! Secrets are respected: a clipboard carrying `ExcludeClipboardContentFromMonitorProcessing`
//! (what password managers set) is never announced and never served — the Windows counterpart
//! of §5.2's `org.nspasteboard.ConcealedType` skip.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use punktfunk_core::client::NativeClient;
use punktfunk_core::clipboard::ClipEventCore;
use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD};
/// Wire mime for UTF-8 text — the one format every peer must handle (§3.5).
const MIME_TEXT: &str = "text/plain;charset=utf-8";
/// Wire mime for the image floor (§3.5). Read/written through the "PNG" registered clipboard
/// format; apps that only publish `CF_DIB` are a follow-up (the conversion the host already
/// has in `image_to_dib`).
const MIME_PNG: &str = "image/png";
/// Ceiling on an EAGERLY fetched remote payload (see the module docs). Text never approaches
/// it; it exists so a host-side copy of something enormous doesn't cross for a paste that may
/// never happen. Lifted once delayed rendering makes the fetch lazy.
const EAGER_FETCH_CAP: u64 = 4 << 20;
/// How often the local clipboard is polled for changes. §3.2 asks for ≥ 100 ms between offers;
/// 400 ms keeps a copy→focus→paste round trip comfortably ahead of the user.
const POLL: Duration = Duration::from_millis(400);
/// Drain-and-poll cadence: how long `next_clip` blocks before we re-check the clipboard and
/// the stop flag.
const EVENT_WAIT: Duration = Duration::from_millis(120);
/// Run the clipboard bridge until `stop` is set or the session closes. Returns immediately
/// (doing nothing) when the host didn't advertise `HOST_CAP_CLIPBOARD` — an older host, or one
/// whose backend can't do it — so this is safe to spawn unconditionally.
pub fn run(client: Arc<NativeClient>, stop: Arc<AtomicBool>) {
if client.host_caps() & HOST_CAP_CLIPBOARD == 0 {
tracing::info!("host has no clipboard capability — shared clipboard off");
return;
}
// Opt-in per §3.1: nothing is announced or served until this crosses enabled.
if let Err(e) = client.clip_control(true, 0) {
tracing::warn!(error = %e, "clipboard: enable failed");
return;
}
tracing::info!("shared clipboard enabled");
let mut state = State {
// Adopt the CURRENT sequence number without announcing: whatever is on the clipboard
// from before the session started is the user's, not a copy they made for this stream.
last_seq: os::sequence_number(),
..Default::default()
};
let mut next_poll = Instant::now() + POLL;
while !stop.load(Ordering::SeqCst) {
// Inbound first — a pending FetchRequest is the host waiting on us. `NoFrame` is the
// ordinary poll timeout (nothing pending); anything else means the connection is gone
// and the session teardown is already on its way.
match client.next_clip(EVENT_WAIT) {
Ok(ev) => handle_event(&client, &mut state, ev),
Err(punktfunk_core::error::PunktfunkError::NoFrame) => {}
Err(_) => break,
}
// The local clipboard is polled on its OWN cadence, not once per inbound wait: the
// event wait is short (it bounds teardown latency), and hammering the Win32 clipboard
// eight times a second would contend with whatever app the user is actually copying in.
let now = Instant::now();
if now >= next_poll {
poll_local(&client, &mut state);
next_poll = now + POLL;
}
}
// Best-effort: tell the host to stop announcing into a session that's ending.
let _ = client.clip_control(false, 0);
}
#[derive(Default)]
struct State {
/// Clipboard sequence number as of our last look — a change means someone copied.
last_seq: u32,
/// The sequence number our OWN `SetClipboardData` produced (§3.4 echo suppression).
self_written_seq: Option<u32>,
/// Monotonic offer counter (§3.2 — newest wins).
offer_seq: u32,
/// Rate-limit guard for offers (§3.2 asks ≥ 100 ms).
last_offer: Option<Instant>,
/// The host's current offer, so a fetch can name its `seq`.
remote_offer: Option<u32>,
/// In-flight eager fetch → the mime it will deliver, so `Data` knows how to place it.
pending_fetch: Option<(u32, String)>,
/// The last payload we placed locally, kept only to answer a host fetch of our own echo
/// without re-reading the OS clipboard.
last_applied: Option<(String, Vec<u8>)>,
}
/// A local clipboard change → announce the format list (never the bytes).
fn poll_local(client: &NativeClient, state: &mut State) {
let seq = os::sequence_number();
if seq == state.last_seq {
return;
}
state.last_seq = seq;
// Our own apply — swallow it, or the two clipboards chase each other forever.
if state.self_written_seq == Some(seq) {
state.self_written_seq = None;
return;
}
if let Some(t) = state.last_offer {
if t.elapsed() < Duration::from_millis(100) {
return;
}
}
if os::is_concealed() {
tracing::debug!("clipboard: concealed content — not announced");
return;
}
let mut kinds: Vec<ClipKind> = Vec::new();
for (mime, size) in os::available_kinds() {
kinds.push(ClipKind {
mime: mime.to_string(),
size_hint: size,
});
}
if kinds.is_empty() {
return;
}
state.offer_seq = state.offer_seq.wrapping_add(1);
state.last_offer = Some(Instant::now());
let seq_id = state.offer_seq;
tracing::debug!(
seq = seq_id,
kinds = kinds.len(),
"clipboard: offering local copy"
);
if let Err(e) = client.clip_offer(seq_id, kinds) {
tracing::warn!(error = %e, "clipboard: offer failed");
}
}
fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
match ev {
ClipEventCore::State {
enabled,
policy,
reason,
} => {
tracing::info!(enabled, policy, reason, "clipboard: host state");
}
// The host copied. Pull the best format we can place (see the module docs on why this
// is eager for now) — text preferred, then PNG.
ClipEventCore::RemoteOffer { seq, kinds } => {
state.remote_offer = Some(seq);
let pick = kinds
.iter()
.find(|k| k.mime == MIME_TEXT)
.or_else(|| kinds.iter().find(|k| k.mime == MIME_PNG));
let Some(kind) = pick else {
tracing::debug!("clipboard: remote offer has no format we can place");
return;
};
if kind.size_hint > EAGER_FETCH_CAP {
tracing::info!(
mime = %kind.mime,
size = kind.size_hint,
"clipboard: remote payload over the eager-fetch cap — not mirrored"
);
return;
}
match client.clip_fetch(seq, kind.mime.clone(), CLIP_FILE_INDEX_NONE) {
Ok(xfer) => state.pending_fetch = Some((xfer, kind.mime.clone())),
Err(e) => tracing::warn!(error = %e, "clipboard: fetch failed to start"),
}
}
// Bytes for the fetch above — place them, then record the sequence number they cause.
ClipEventCore::Data {
xfer_id,
bytes,
last,
} => {
let Some((pending, mime)) = state.pending_fetch.clone() else {
return;
};
if pending != xfer_id {
return;
}
if last {
state.pending_fetch = None;
}
match os::set(&mime, &bytes) {
Ok(()) => {
// §3.4: this is the change WE caused; ignore exactly it.
state.self_written_seq = Some(os::sequence_number());
state.last_applied = Some((mime.clone(), bytes));
tracing::debug!(mime = %mime, "clipboard: applied remote content");
}
Err(e) => tracing::warn!(error = %e, mime = %mime, "clipboard: apply failed"),
}
}
// The host is pasting what we offered: read the bytes NOW (this is the lazy half) and
// answer. A read failure still answers — with a cancel — so the host isn't left waiting.
ClipEventCore::FetchRequest {
req_id,
seq: _,
file_index: _,
mime,
} => {
if os::is_concealed() {
let _ = client.clip_cancel(req_id);
return;
}
// Serve our own last-applied payload verbatim when it still matches — avoids a
// lossy OS round trip for content that originated on the host anyway.
let bytes = match &state.last_applied {
Some((m, b)) if *m == mime && state.self_written_seq.is_some() => Ok(b.clone()),
_ => os::get(&mime),
};
match bytes {
Ok(b) => {
tracing::debug!(mime = %mime, len = b.len(), "clipboard: serving to host");
if let Err(e) = client.clip_serve(req_id, b, true) {
tracing::warn!(error = %e, "clipboard: serve failed");
}
}
Err(e) => {
tracing::debug!(error = %e, mime = %mime, "clipboard: nothing to serve");
let _ = client.clip_cancel(req_id);
}
}
}
ClipEventCore::Cancelled { id } => tracing::debug!(id, "clipboard: transfer cancelled"),
ClipEventCore::Error { id, code } => {
tracing::debug!(id, code, "clipboard: transfer error");
if state.pending_fetch.as_ref().is_some_and(|(x, _)| *x == id) {
state.pending_fetch = None;
}
}
}
}
#[cfg(windows)]
mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
//! closes it — holding it across a network fetch would block every other app on the box.
use super::{MIME_PNG, MIME_TEXT};
use anyhow::{anyhow, bail, Result};
use windows::core::PCWSTR;
use windows::Win32::Foundation::{HANDLE, HGLOBAL};
use windows::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber,
IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
};
use windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
};
const CF_UNICODETEXT: u32 = 13;
/// A registered clipboard format id, by name (`PNG`, the concealed marker, …).
fn registered(name: &str) -> u32 {
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) }
}
fn png_format() -> u32 {
registered("PNG")
}
/// RAII clipboard open — `CloseClipboard` must run even on the error paths.
struct Clip;
impl Clip {
fn open() -> Result<Clip> {
// A retry loop: another app can hold the clipboard for a moment.
for _ in 0..10 {
if unsafe { OpenClipboard(None) }.is_ok() {
return Ok(Clip);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
bail!("clipboard busy")
}
}
impl Drop for Clip {
fn drop(&mut self) {
let _ = unsafe { CloseClipboard() };
}
}
pub fn sequence_number() -> u32 {
unsafe { GetClipboardSequenceNumber() }
}
/// Password managers mark secrets with this format; §5.2's concealed-type rule.
pub fn is_concealed() -> bool {
let fmt = registered("ExcludeClipboardContentFromMonitorProcessing");
unsafe { IsClipboardFormatAvailable(fmt) }.is_ok()
}
/// The wire kinds the current clipboard can supply, with size hints where they're free.
pub fn available_kinds() -> Vec<(&'static str, u64)> {
let mut out = Vec::new();
if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_ok() {
out.push((MIME_TEXT, 0));
}
if unsafe { IsClipboardFormatAvailable(png_format()) }.is_ok() {
out.push((MIME_PNG, 0));
}
out
}
/// Read one wire format off the clipboard.
pub fn get(mime: &str) -> Result<Vec<u8>> {
let _clip = Clip::open()?;
match mime {
MIME_TEXT => {
let h = unsafe { GetClipboardData(CF_UNICODETEXT) }?;
let g = HGLOBAL(h.0);
let p = unsafe { GlobalLock(g) } as *const u16;
if p.is_null() {
bail!("clipboard text lock failed");
}
// GlobalSize is a byte count of a NUL-terminated UTF-16 buffer.
let bytes = unsafe { GlobalSize(g) };
let mut len = bytes / 2;
let slice = unsafe { std::slice::from_raw_parts(p, len) };
if let Some(nul) = slice.iter().position(|&c| c == 0) {
len = nul;
}
let text = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) });
let _ = unsafe { GlobalUnlock(g) };
Ok(text.into_bytes())
}
MIME_PNG => {
let h = unsafe { GetClipboardData(png_format()) }?;
let g = HGLOBAL(h.0);
let p = unsafe { GlobalLock(g) } as *const u8;
if p.is_null() {
bail!("clipboard png lock failed");
}
let len = unsafe { GlobalSize(g) };
let out = unsafe { std::slice::from_raw_parts(p, len) }.to_vec();
let _ = unsafe { GlobalUnlock(g) };
Ok(out)
}
other => Err(anyhow!("unsupported clipboard format {other}")),
}
}
/// Place one wire format on the clipboard, replacing its contents.
pub fn set(mime: &str, bytes: &[u8]) -> Result<()> {
let (fmt, payload) = match mime {
MIME_TEXT => {
let text = String::from_utf8_lossy(bytes);
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
let raw: Vec<u8> = wide.iter().flat_map(|c| c.to_le_bytes()).collect();
(CF_UNICODETEXT, raw)
}
MIME_PNG => (png_format(), bytes.to_vec()),
other => bail!("unsupported clipboard format {other}"),
};
let _clip = Clip::open()?;
unsafe { EmptyClipboard() }?;
// The clipboard OWNS this block once SetClipboardData succeeds — do not free it.
let g = unsafe { GlobalAlloc(GMEM_MOVEABLE, payload.len()) }?;
let p = unsafe { GlobalLock(g) } as *mut u8;
if p.is_null() {
bail!("clipboard alloc lock failed");
}
unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), p, payload.len()) };
let _ = unsafe { GlobalUnlock(g) };
unsafe { SetClipboardData(fmt, Some(HANDLE(g.0))) }?;
Ok(())
}
}
#[cfg(not(windows))]
mod os {
//! Non-Windows stub. Linux needs the Wayland `data-control` seam (the same protocol the
//! host side already speaks) — the bridge above is platform-neutral and will drive it
//! unchanged once this module grows a real implementation.
use anyhow::{bail, Result};
pub fn sequence_number() -> u32 {
0
}
pub fn is_concealed() -> bool {
false
}
pub fn available_kinds() -> Vec<(&'static str, u64)> {
Vec::new()
}
pub fn get(_mime: &str) -> Result<Vec<u8>> {
bail!("clipboard unsupported on this platform")
}
pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> {
bail!("clipboard unsupported on this platform")
}
}
-5
View File
@@ -41,11 +41,6 @@ mod video_software;
mod video_vaapi; mod video_vaapi;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
mod video_vulkan; mod video_vulkan;
// The OS-clipboard bridge for the shared clipboard (design/clipboard-and-file-transfer.md §5).
// Built everywhere the session client is; the platform seam inside is Windows-real,
// stub elsewhere.
#[cfg(any(target_os = "linux", windows))]
pub mod clipboard;
// PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase). // PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase).
// Windows joined once its client moved to the SAME spawned Vulkan session presenter as // Windows joined once its client moved to the SAME spawned Vulkan session presenter as
// Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no // Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no
+2 -30
View File
@@ -41,9 +41,6 @@ pub struct SessionParams {
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>, pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool, pub mic_enabled: bool,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see /// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
/// `video::Decoder::new`). /// `video::Decoder::new`).
pub decoder: String, pub decoder: String,
@@ -342,20 +339,6 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own // app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client. // thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone()); let audio_thread = spawn_audio(connector.clone(), stop.clone());
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
let clipboard_thread = params
.clipboard
.then(|| {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
let _mic = params let _mic = params
.mic_enabled .mic_enabled
.then(|| { .then(|| {
@@ -447,16 +430,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;
@@ -824,9 +799,6 @@ fn pump(
if let Some(t) = audio_thread { if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
} }
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
} }
-32
View File
@@ -124,12 +124,6 @@ pub struct KnownHost {
/// pre-existing stores load; empty until first learned. /// pre-existing stores load; empty until first learned.
#[serde(default)] #[serde(default)]
pub mac: Vec<String>, pub mac: Vec<String>,
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
/// host your clipboard is a trust decision about that host. Default off; the host must
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
#[serde(default)]
pub clipboard_sync: bool,
} }
#[derive(Default, Serialize, Deserialize)] #[derive(Default, Serialize, Deserialize)]
@@ -207,7 +201,6 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
paired, paired,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
let _ = known.save(); let _ = known.save();
} }
@@ -533,27 +526,6 @@ pub struct Settings {
/// Experimental: the game-library browser ("Browse library…" on saved cards) — /// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off. /// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool, pub library_enabled: bool,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
/// an offline-looking host is really just unreachable by broadcast and the wake +
/// wait only adds a delay.
#[serde(default = "default_true")]
pub auto_wake: bool,
/// Reverse the wheel/trackpad scroll direction sent to the host (the Apple client's
/// "Invert scroll direction"). Default off = the host scrolls the way this machine does.
#[serde(default)]
pub invert_scroll: bool,
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
/// playback stream targets (`target.object`); empty = the session default (the
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
/// Ignored on Windows until the WASAPI endpoint leg exists.
#[serde(default)]
pub speaker_device: String,
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the /// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel /// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder /// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -642,10 +614,6 @@ impl Default for Settings {
stats_verbosity: None, stats_verbosity: None,
fullscreen_on_stream: true, fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
auto_wake: true,
invert_scroll: false,
speaker_device: String::new(),
mic_device: String::new(),
match_window: false, match_window: false,
last_window_w: 0, last_window_w: 0,
last_window_h: 0, last_window_h: 0,
-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 -39
View File
@@ -865,13 +865,6 @@ impl PyroWaveEncoder {
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command /// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`. /// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null. Callers today turn that into
// a session error and never resubmit, but a null here would be a use-after-free inside
// pyrowave rather than a clean error — so fail loudly instead of relying on that.
anyhow::ensure!(
!self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
);
let dev = self.device.clone(); let dev = self.device.clone();
let (w, h) = (self.width, self.height); let (w, h) = (self.width, self.height);
dev.begin_command_buffer( dev.begin_command_buffer(
@@ -1176,24 +1169,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 {
@@ -1219,12 +1195,6 @@ impl Encoder for PyroWaveEncoder {
unsafe { unsafe {
self.device.device_wait_idle().ok(); self.device.device_wait_idle().ok();
pw::pyrowave_encoder_destroy(self.pw_enc); pw::pyrowave_encoder_destroy(self.pw_enc);
// Publish the null IMMEDIATELY: the create below is fallible, and its failure path
// must not leave a freed pointer in the field. `pyrowave_encoder_destroy` is a plain
// `delete` (pyrowave_c.cpp) with no null check, so `Drop` running on a stale handle
// is a double free — the exact shape this reset hits when the rebuild fails because
// the device is already lost, which is the state that made the watchdog fire.
self.pw_enc = std::ptr::null_mut();
let einfo = pw::pyrowave_encoder_create_info { let einfo = pw::pyrowave_encoder_create_info {
device: self.pw_dev, device: self.pw_dev,
width: self.width as i32, width: self.width as i32,
@@ -1239,10 +1209,6 @@ impl Encoder for PyroWaveEncoder {
let r = pw::pyrowave_encoder_create(&einfo, &mut enc); let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS { if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed"); tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it. The queued
// AUs are forfeit either way (the caller turns a false reset into a session
// error), so drop them rather than shipping output from a dead encoder.
self.pending.clear();
return false; return false;
} }
self.pw_enc = enc; self.pw_enc = enc;
@@ -1288,11 +1254,7 @@ impl Drop for PyroWaveEncoder {
// before the VkDevice they borrow (encoder before device, per pyrowave.h). // before the VkDevice they borrow (encoder before device, per pyrowave.h).
unsafe { unsafe {
self.device.device_wait_idle().ok(); self.device.device_wait_idle().ok();
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
// is not null-safe.
if !self.pw_enc.is_null() {
pw::pyrowave_encoder_destroy(self.pw_enc); pw::pyrowave_encoder_destroy(self.pw_enc);
}
pw::pyrowave_device_destroy(self.pw_dev); pw::pyrowave_device_destroy(self.pw_dev);
for (_, _, i, m, v) in self.import_cache.drain(..) { for (_, _, i, m, v) in self.import_cache.drain(..) {
self.device.destroy_image_view(v, None); self.device.destroy_image_view(v, None);
+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
-19
View File
@@ -2077,16 +2077,10 @@ impl Encoder for AmfEncoder {
} }
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it // Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on. // becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
// Guard against a slot the taint sweep emptied since the force was queued: the
// HARDWARE slot still holds the tainted mark, so forcing it would re-reference the
// very corruption being recovered from — and the frame must not ship tagged
// `recovery_anchor` either (the client lifts its post-loss freeze on that tag).
if let Some(slot) = self.pending_force.take() { if let Some(slot) = self.pending_force.take() {
if self.ltr_slots[slot].is_some() {
force_slot = Some(slot); force_slot = Some(slot);
recovery_anchor = true; recovery_anchor = true;
} }
}
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval` // Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and // frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
// could overwrite the very slot being forced; the next cadence mark re-establishes it). // could overwrite the very slot being forced; the next cadence mark re-establishes it).
@@ -2369,16 +2363,6 @@ impl Encoder for AmfEncoder {
if !self.ltr_active || first < 0 || first > last { if !self.ltr_active || first < 0 || first > last {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
// encoded inside the client's corrupt window — the client either never received it or
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
for marked in self.ltr_slots.iter_mut() {
if marked.is_some_and(|idx| idx >= first) {
*marked = None;
}
}
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the // Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual). // client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins // `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
@@ -2407,9 +2391,6 @@ impl Encoder for AmfEncoder {
true true
} }
None => { None => {
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
// at — clear it so the next submit can't force-reference a tainted hardware slot.
self.pending_force = None;
tracing::info!( tracing::info!(
first, first,
last, last,
+7 -56
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() {
@@ -1150,19 +1135,7 @@ impl Encoder for NvencD3d11Encoder {
self.chroma_444 = false; self.chroma_444 = false;
} }
let device = frame.device.clone(); let device = frame.device.clone();
// `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its self.init_session(&device)?;
// last fallible steps, so a failure there leaves a live session with `inited == false`.
// Every guard on the re-init path keys off `inited`, so without this the next submit
// would skip teardown and overwrite `self.encoder` — leaking the session permanently
// (toward the driver's per-process cap) along with its session-budget units.
// `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
// half-built state and is a no-op when nothing was opened.
if let Err(e) = self.init_session(&device) {
// SAFETY: same contract as the teardown above — the encode thread owns the session,
// and a failed init leaves nothing mid-encode to race with.
unsafe { self.teardown() };
return Err(e);
}
self.init_device = dev_raw; self.init_device = dev_raw;
} }
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the // The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
@@ -1171,21 +1144,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 +1324,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 +1528,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 -73
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);
@@ -431,27 +417,6 @@ impl PyroWaveEncoder {
/// # Safety /// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns. /// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
anyhow::ensure!(
!self.pw_enc.is_null(),
"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 +425,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 +459,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 +468,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,
@@ -693,10 +639,6 @@ impl Encoder for PyroWaveEncoder {
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder. // SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
unsafe { unsafe {
pw::pyrowave_encoder_destroy(self.pw_enc); pw::pyrowave_encoder_destroy(self.pw_enc);
// Publish the null IMMEDIATELY — see the Linux twin. The create below is fallible and
// `pyrowave_encoder_destroy` is a plain `delete` with no null check, so leaving the
// freed pointer in the field makes `Drop` a double free.
self.pw_enc = std::ptr::null_mut();
let einfo = pw::pyrowave_encoder_create_info { let einfo = pw::pyrowave_encoder_create_info {
device: self.pw_dev, device: self.pw_dev,
width: self.width as i32, width: self.width as i32,
@@ -713,8 +655,6 @@ impl Encoder for PyroWaveEncoder {
let r = pw::pyrowave_encoder_create(&einfo, &mut enc); let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
if r != pw::pyrowave_result_PYROWAVE_SUCCESS { if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed"); tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it.
self.pending.clear();
return false; return false;
} }
self.pw_enc = enc; self.pw_enc = enc;
@@ -758,11 +698,7 @@ impl Drop for PyroWaveEncoder {
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go // SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
// before the device they borrow (per pyrowave.h). // before the device they borrow (per pyrowave.h).
unsafe { unsafe {
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
// is not null-safe (same guard `sync` below has had all along).
if !self.pw_enc.is_null() {
pw::pyrowave_encoder_destroy(self.pw_enc); pw::pyrowave_encoder_destroy(self.pw_enc);
}
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) { for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
pw::pyrowave_image_destroy(img); pw::pyrowave_image_destroy(img);
} }
@@ -1024,9 +960,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,
+20 -402
View File
@@ -146,12 +146,7 @@ const NUM_LTR_SLOTS: usize = 2;
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with /// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR. /// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
fn ltr_disabled() -> bool { fn ltr_disabled() -> bool {
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
// silently left LTR enabled on Intel while the identical value worked on AMD.
std::env::var("PUNKTFUNK_NO_QSV_LTR")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
} }
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s /// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
@@ -176,22 +171,13 @@ fn ltr_test_force_at() -> Option<i64> {
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave /// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references). /// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
fn intra_refresh_requested() -> bool { fn intra_refresh_requested() -> bool {
// Spelling parity with AMF (see `ltr_disabled` above).
std::env::var("PUNKTFUNK_INTRA_REFRESH") std::env::var("PUNKTFUNK_INTRA_REFRESH")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on")) .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
} }
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and /// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF.
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
fn intra_refresh_period(fps: u32) -> u16 { fn intra_refresh_period(fps: u32) -> u16 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES") (fps / 2).clamp(8, 240) as u16
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or(fps / 2)
.clamp(8, 240) as u16
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -478,14 +464,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 +475,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 +700,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 +775,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 +899,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;
@@ -1052,14 +1017,13 @@ impl Encoder for QsvEncoder {
self.frame_idx += 1; self.frame_idx += 1;
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) --- // --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
let mut mark_slot: Option<usize> = None; let mut mark_slot: Option<usize> = None;
let mut force_ltr: Option<(usize, i64)> = None; let mut force_slot: Option<usize> = None;
let mut recovery_anchor = false; let mut recovery_anchor = false;
if self.ltr_active { if self.ltr_active {
if forced { if forced {
// 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) {
@@ -1071,29 +1035,17 @@ impl Encoder for QsvEncoder {
); );
} }
if let Some(slot) = self.pending_force.take() { if let Some(slot) = self.pending_force.take() {
// Resolve the anchor NOW: a taint sweep in `invalidate_ref_frames` may have force_slot = Some(slot);
// 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
// `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
// 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));
recovery_anchor = true; recovery_anchor = true;
} }
} if force_slot.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);
} }
} }
let ltr_slots = self.ltr_slots; let ltr_slots = self.ltr_slots;
let reject_ok = self.codec != Codec::Av1;
let inner = self.inner.as_mut().expect("ensure_inner succeeded"); let inner = self.inner.as_mut().expect("ensure_inner succeeded");
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for // Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
// `poll`) instead of letting the queue grow under overload. // `poll`) instead of letting the queue grow under overload.
@@ -1178,7 +1130,7 @@ impl Encoder for QsvEncoder {
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz (*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
// Per-frame control: forced IDR and/or the LTR reflist. // Per-frame control: forced IDR and/or the LTR reflist.
let mut ctrl: Option<Box<FrameCtrl>> = None; let mut ctrl: Option<Box<FrameCtrl>> = None;
if forced || mark_slot.is_some() || force_ltr.is_some() { if forced || mark_slot.is_some() || force_slot.is_some() {
let mut c = FrameCtrl::new(); let mut c = FrameCtrl::new();
if forced { if forced {
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
@@ -1196,7 +1148,8 @@ impl Encoder for QsvEncoder {
c.reflist.ApplyLongTermIdx = 1; c.reflist.ApplyLongTermIdx = 1;
use_reflist = true; use_reflist = true;
} }
if let Some((slot, ltr_frame)) = force_ltr { if let Some(slot) = force_slot {
if let Some(ltr_frame) = ltr_slots[slot] {
// Force THIS frame to predict only from the known-good LTR — the // Force THIS frame to predict only from the known-good LTR — the
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList // clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on // (the AV1 runtime rejects nonzero there; AVC/HEVC key on
@@ -1204,39 +1157,6 @@ impl Encoder for QsvEncoder {
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32; c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
c.reflist.PreferredRefList[0].PicStruct = c.reflist.PreferredRefList[0].PicStruct =
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16; vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
// A preference alone is a reorder HINT (VPL spec) — the encoder may
// still predict from the tainted short-term refs alongside it. AMF's
// ForceLTRReferenceBitfield and NVENC's invalidation are hard
// exclusions; emulate that here by rejecting every other DPB
// candidate — the short-term sliding window (the 2 most recent
// frames) and the other LTR slot — and capping L0 at one active
// entry. Without this the "clean recovery" frame can carry the
// corruption forward, which the client cannot detect (the field
// failure: permanent macroblock soup under sustained loss).
// AVC/HEVC only: the AV1 runtime's universal-reflist rejection path
// is unvalidated, and an unhonored hint there still converges via
// the host's IDR escalation.
if reject_ok {
let mut rej = 0;
let mut reject = |idx: i64| {
if idx >= 0 && idx != ltr_frame {
c.reflist.RejectedRefList[rej].FrameOrder = idx as u32;
c.reflist.RejectedRefList[rej].PicStruct =
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
rej += 1;
}
};
reject(cur_idx - 1);
reject(cur_idx - 2);
for (s, marked) in ltr_slots.iter().enumerate() {
if s != slot {
if let Some(idx) = *marked {
reject(idx);
}
}
}
c.reflist.NumRefIdxL0Active = 1;
}
use_reflist = true; use_reflist = true;
tracing::info!( tracing::info!(
slot, slot,
@@ -1246,6 +1166,7 @@ impl Encoder for QsvEncoder {
no IDR)" no IDR)"
); );
} }
}
if use_reflist { if use_reflist {
c.attach_reflist(); c.attach_reflist();
} }
@@ -1344,29 +1265,8 @@ impl Encoder for QsvEncoder {
if !self.ltr_active || first < 0 || first > last { if !self.ltr_active || first < 0 || first > last {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
// encoded inside the client's corrupt window — the client either never received it or
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
// the soup — the sustained-loss field failure where the picture never healed. Dropped
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
//
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
// could still predict from it. With two slots the "exactly one swept" case is the modal
// one, and it was the broken one.
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if marked.is_some_and(|idx| idx >= first) {
self.ltr_tainted[slot] = true;
}
}
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));
@@ -1387,9 +1287,6 @@ impl Encoder for QsvEncoder {
true true
} }
None => { None => {
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
// at — clear it so the next submit can't half-apply a stale recovery.
self.pending_force = None;
tracing::info!( tracing::info!(
first, first,
last, last,
@@ -1469,21 +1366,15 @@ impl Encoder for QsvEncoder {
let inner = self.inner.as_mut().expect("checked above"); let inner = self.inner.as_mut().expect("checked above");
// Best-effort settle of in-flight operations (Close aborts them anyway). // Best-effort settle of in-flight operations (Close aborts them anyway).
while sync_one(inner, 5).ok().flatten().is_some() {} while sync_one(inner, 5).ok().flatten().is_some() {}
// Close BEFORE dropping `pending`. Each `Pending` owns the `Box<BsBuf>` the runtime inner.pending.clear();
// is writing into asynchronously (and a `Box<FrameCtrl>` it reads), so clearing first inner.ready.clear();
// frees that heap while the operation is still live — a use-after-free by the VPL inner.frames_submitted = 0;
// runtime. The drain above is best-effort and bails on the first `Err`, which is inner.first_au_logged = false;
// exactly the wedged-encoder case that triggers this reset, so it cannot be relied on
// to have retired everything. Close aborts the operations; only then is the drop safe.
// SAFETY: the session is live on this thread; Close on a wedged encoder is legal // SAFETY: the session is live on this thread; Close on a wedged encoder is legal
// (result deliberately ignored) and re-Init happens through `init_encode`. // (result deliberately ignored) and re-Init happens through `init_encode`.
unsafe { unsafe {
let _ = vpl::MFXVideoENCODE_Close(inner.session.0); let _ = vpl::MFXVideoENCODE_Close(inner.session.0);
} }
inner.pending.clear();
inner.ready.clear();
inner.frames_submitted = 0;
inner.first_au_logged = false;
inner.session.0 inner.session.0
}; };
match self.init_encode(rebuilt) { match self.init_encode(rebuilt) {
@@ -1491,7 +1382,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() {
@@ -1955,36 +1845,6 @@ mod tests {
); );
} }
/// Taint sweep: a loss that predates every live LTR mark leaves NO clean anchor — every
/// slot was marked inside the client's corrupt window. The invalidate must decline (the
/// caller then serves the IDR) and no recovery_anchor AU may ship; before the sweep this
/// force-referenced a tainted mark and shipped corruption tagged as a clean recovery.
#[test]
fn qsv_live_ltr_rfi_taint_sweep_declines() {
let mut rfi_answered = None;
let Some(aus) = drive_live(Codec::H264, false, 60, |enc, i| {
if i == 30 && enc.caps().supports_rfi {
// Frame 0 lost: the IDR itself — every mark (0, 15, ...) is at-or-after it.
rfi_answered = Some(enc.invalidate_ref_frames(0, 2));
}
}) else {
return;
};
assert_stream_shape(&aus, 60, true);
let Some(answered) = rfi_answered else {
eprintln!("note: driver declined LTR (supports_rfi=false) — sweep not exercised");
return;
};
assert!(
!answered,
"a loss covering every live LTR mark must fall back to IDR recovery"
);
assert!(
!aus.iter().any(|a| a.recovery_anchor),
"no recovery_anchor AU may ship when the sweep left no clean LTR"
);
}
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be /// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe. /// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
#[test] #[test]
@@ -2004,246 +1864,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()
);
}
} }
+7 -123
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()
} }
@@ -246,13 +223,6 @@ impl Encoder for TrackedEncoder {
} }
} }
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
/// arm was missing the clamp the Windows arm applied.
#[cfg(any(target_os = "linux", target_os = "windows"))]
const SW_BITRATE_CEIL: u64 = 100_000_000;
/// Open the platform encoder backend. Returns the encoder together with the display label of the /// Open the platform encoder backend. Returns the encoder together with the display label of the
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds /// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
/// the mgmt API's live-session record, and only the open site knows which internal fallback won /// the mgmt API's live-session record, and only the open site knows which internal fallback won
@@ -268,9 +238,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 +296,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 +306,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 +350,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"))]
@@ -474,13 +407,7 @@ fn open_video_backend(
); );
} }
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
sw::OpenH264Encoder::open( sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
format,
width,
height,
fps,
bitrate_bps.min(SW_BITRATE_CEIL),
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software")) .map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
} }
"auto" | "" => { "auto" | "" => {
@@ -683,6 +610,8 @@ fn open_video_backend(
(build a GPU backend: --features nvenc or amf-qsv, or request H264)" (build a GPU backend: --features nvenc or amf-qsv, or request H264)"
); );
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
// Software H.264 realistically caps far below the negotiated hardware rates.
const SW_BITRATE_CEIL: u64 = 100_000_000;
sw::OpenH264Encoder::open( sw::OpenH264Encoder::open(
format, format,
width, width,
@@ -841,33 +770,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
@@ -882,12 +784,6 @@ fn nvidia_present() -> bool {
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still /// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise /// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
/// today's NVIDIA-presence probe, unchanged. /// today's NVIDIA-presence probe, unchanged.
///
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
/// forced one.)
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn linux_auto_is_vaapi() -> bool { fn linux_auto_is_vaapi() -> bool {
if let Some(g) = pf_gpu::manual_selection() { if let Some(g) = pf_gpu::manual_selection() {
@@ -1101,13 +997,7 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode) // only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP // is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
// honor), since this probe can't know what the compositor will negotiate. // honor), since this probe can't know what the compositor will negotiate.
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors if linux_auto_is_vaapi() {
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
// never runs. That is exactly the dishonesty this probe exists to prevent.
if linux_zero_copy_is_vaapi() {
vaapi::probe_can_encode_10bit(codec) vaapi::probe_can_encode_10bit(codec)
} else { } else {
linux::probe_can_encode_10bit(codec) linux::probe_can_encode_10bit(codec)
@@ -1425,12 +1315,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,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 -11
View File
@@ -53,8 +53,6 @@ pub struct Capture {
/// The touchscreen input model for this session, and — for trackpad/pointer — the /// The touchscreen input model for this session, and — for trackpad/pointer — the
/// gesture state machine finger events feed. /// gesture state machine finger events feed.
touch_mode: TouchMode, touch_mode: TouchMode,
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
invert_scroll: bool,
gestures: Gestures, gestures: Gestures,
} }
@@ -70,11 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
} }
impl Capture { impl Capture {
pub fn new( pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
connector: Arc<NativeClient>,
touch_mode: TouchMode,
invert_scroll: bool,
) -> Capture {
Capture { Capture {
connector, connector,
captured: false, captured: false,
@@ -85,7 +79,6 @@ impl Capture {
scroll_acc: (0.0, 0.0), scroll_acc: (0.0, 0.0),
touch_slots: HashMap::new(), touch_slots: HashMap::new(),
touch_mode, touch_mode,
invert_scroll,
gestures: Gestures::new(touch_mode == TouchMode::Trackpad), gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
} }
} }
@@ -201,10 +194,9 @@ impl Capture {
return; return;
} }
self.flush_motion(); // scroll happens at the latest cursor position self.flush_motion(); // scroll happens at the latest cursor position
let sign = if self.invert_scroll { -1.0 } else { 1.0 };
let (mut ax, mut ay) = self.scroll_acc; let (mut ax, mut ay) = self.scroll_acc;
ay += f64::from(dy) * 120.0 * sign; ay += f64::from(dy) * 120.0;
ax += f64::from(dx) * 120.0 * sign; ax += f64::from(dx) * 120.0;
let vy = ay.trunc() as i32; let vy = ay.trunc() as i32;
if vy != 0 { if vy != 0 {
ay -= f64::from(vy); ay -= f64::from(vy);
+1 -8
View File
@@ -48,8 +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,
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
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.
pub json_status: bool, pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the /// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -333,11 +331,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")?;
@@ -818,7 +811,7 @@ 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());
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll); let mut cap = Capture::new(c.clone(), opts.touch_mode);
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); apply_capture(&mut window, &mouse, true);
st.capture = Some(cap); st.capture = Some(cap);
-2
View File
@@ -33,8 +33,6 @@ mod reconfig;
mod resources; mod resources;
mod setup; mod setup;
pub use setup::list_adapters;
/// One presenter iteration's video input. /// One presenter iteration's video input.
pub enum FrameInput<'a> { pub enum FrameInput<'a> {
/// No new frame — re-composite the retained video image (expose/resize). /// No new frame — re-composite the retained video image (expose/resize).
+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()),
-55
View File
@@ -497,52 +497,6 @@ impl Presenter {
} }
} }
/// The physical devices' marketing names — the shells' GPU-picker source
/// (`punktfunk-session --list-adapters`). No surface and no logical device; discrete
/// GPUs first (mirroring `pick_device`'s tie-break), duplicates collapsed (the name is
/// the whole `PUNKTFUNK_VK_ADAPTER` match key, so a second identical card adds nothing).
/// Same 1.3 instance the presenter creates, so the list matches what streaming sees.
pub fn list_adapters() -> Result<Vec<String>> {
let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?;
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
let instance = unsafe {
entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app_info),
None,
)
}
.context("vkCreateInstance")?;
let mut ranked: Vec<(u8, String)> = unsafe { instance.enumerate_physical_devices() }?
.into_iter()
.map(|d| {
let props = unsafe { instance.get_physical_device_properties(d) };
let rank = match props.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => 0u8,
vk::PhysicalDeviceType::INTEGRATED_GPU => 1,
_ => 2,
};
let name = props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default();
(rank, name)
})
.filter(|(_, n)| !n.is_empty())
.collect();
unsafe { instance.destroy_instance(None) };
ranked.sort_by_key(|(r, _)| *r); // stable: enumeration order within each tier
let mut names: Vec<String> = Vec::new();
for (_, n) in ranked {
if !names.contains(&n) {
names.push(n);
}
}
Ok(names)
}
/// First physical device with a queue family that does graphics + present here; /// First physical device with a queue family that does graphics + present here;
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes. /// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
fn pick_device( fn pick_device(
@@ -617,15 +571,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")?;
+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");
+16 -92
View File
@@ -78,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,
@@ -92,7 +87,7 @@ 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,
key: self.key, key: self.key,
salt: self.salt, salt: self.salt,
@@ -130,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.
@@ -402,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
@@ -468,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)
@@ -898,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;
@@ -908,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.
@@ -919,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
@@ -1766,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
@@ -1929,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) {
@@ -2986,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()
}
} }
}) })
} }
@@ -3083,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
@@ -3251,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,
@@ -3313,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 -28
View File
@@ -425,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);
} }
@@ -462,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,
@@ -714,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
@@ -765,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 {

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