Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dab40ed98e |
@@ -73,34 +73,8 @@ jobs:
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
||||
run: |
|
||||
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
|
||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
|
||||
@@ -1,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
|
||||
@@ -149,26 +149,6 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune Xcode DerivedData
|
||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
@@ -196,7 +176,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
@@ -294,7 +273,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -358,7 +336,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -417,7 +394,6 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
|
||||
@@ -104,9 +104,8 @@ jobs:
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
|
||||
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
Generated
+27
-27
@@ -2159,7 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2264,7 +2264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2832,7 +2832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2850,7 +2850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2894,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2903,7 +2903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2929,11 +2929,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2961,14 +2961,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2983,7 +2983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3013,7 +3013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3025,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3221,7 +3221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3237,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3268,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3287,7 +3287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3318,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3400,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3414,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-150
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.16.0"
|
||||
"version": "0.14.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1348,117 +1348,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "List the library scanners",
|
||||
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
|
||||
"operationId": "listLibraryScanners",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This host's scanners with their enable state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScannerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/library/scanners/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Enable or disable a library scanner",
|
||||
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
|
||||
"operationId": "setLibraryScanner",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The scanner id (e.g. `steam`)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ScannerToggle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Toggle stored; the full scanner list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScannerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such scanner on this platform",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/local/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4782,44 +4671,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScannerToggle": {
|
||||
"type": "object",
|
||||
"description": "Request body for `setLibraryScanner`.",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the scanner should run on this host."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionInfo": {
|
||||
"type": "object",
|
||||
"description": "Client-requested launch parameters (key material is never exposed here).",
|
||||
|
||||
@@ -404,14 +404,7 @@ fn feeder_loop(
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
// (a client-side standing backlog masquerading as network). 0 = older core.
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
{
|
||||
let mut g = in_flight
|
||||
.lock()
|
||||
|
||||
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
|
||||
// samplers (`received` point, host/network split) stay gated on the overlay so
|
||||
// the hidden steady state adds only a wall-clock read + the receipt push.
|
||||
if stats.enabled() || measure_decode {
|
||||
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
|
||||
// async_loop: a pull stamp folds hand-off queue wait into "network".
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let received_ns = now_realtime_ns();
|
||||
in_flight.push_back((frame.pts_ns / 1000, received_ns));
|
||||
if in_flight.len() > IN_FLIGHT_CAP {
|
||||
in_flight.pop_front(); // stale — codec never echoed it back
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.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 */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -504,7 +504,6 @@
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -541,7 +540,6 @@
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -721,7 +719,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -766,7 +764,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 0.9.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -863,15 +861,15 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkKit;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
productName = SwiftUINavigationTransitions;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = AA000000000000000000000D /* Project object */;
|
||||
|
||||
@@ -55,11 +55,6 @@
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "MTL_HUD_ENABLED"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.brand)
|
||||
.foregroundStyle(.tint)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(Color.brand)
|
||||
.foregroundStyle(.tint)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
.foregroundStyle(.tint)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
@@ -184,47 +184,3 @@ private struct EmptyHostView: View {
|
||||
.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)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} 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 {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
HStack(spacing: 6) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.brand)
|
||||
Text(context.attributes.hostName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.padding(.top, 2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 64, alignment: .trailing)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
// The expanded island's height is there to be used: one info row (status
|
||||
// leading, live numbers trailing — the mode string stays on the Lock
|
||||
// Screen), then the platform-conventional LARGE full-width action button.
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
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)
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
} compactTrailing: {
|
||||
CompactReadout(state: context.state)
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(Color.brand)
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
}
|
||||
.keylineTint(Color.brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,20 +69,21 @@ private struct LockScreenView: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.brand)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
ElapsedClock(startedAt: context.state.startedAt)
|
||||
.font(.subheadline)
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
StatusLine(state: context.state)
|
||||
StatsLine(state: context.state)
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
@@ -120,237 +95,46 @@ private struct LockScreenView: View {
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
||||
private struct ElapsedClock: 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 {
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: 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")
|
||||
EmptyView()
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
HStack(spacing: 3) {
|
||||
Text("Background · ends in")
|
||||
Text("Keeps running for")
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
switch state.stage {
|
||||
case .streaming:
|
||||
if let ms = state.latencyMs {
|
||||
Text("\(ms) ms")
|
||||
.font(.caption2.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ElapsedClock(startedAt: state.startedAt)
|
||||
.font(.caption2)
|
||||
.frame(maxWidth: 48)
|
||||
}
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: 48)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
||||
badge("Running in background", .orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
||||
badge("Reconnecting…", .yellow)
|
||||
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).
|
||||
/// `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 {
|
||||
var fullWidth = false
|
||||
|
||||
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()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.buttonStyle(.bordered)
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.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.
|
||||
@State private var showShortcutHint = false
|
||||
#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)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
@@ -198,9 +193,6 @@ struct ContentView: View {
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#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"
|
||||
// plus the accent ring on the most recent host).
|
||||
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
|
||||
// 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
|
||||
// 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).
|
||||
// steering and the next click still reaches the stream.
|
||||
.overlay {
|
||||
if pendingFingerprint == nil, model.resizing {
|
||||
ResizeIndicatorView(active: true)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||||
if pendingFingerprint == nil {
|
||||
ResizeIndicatorView(active: model.resizing)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||||
if let fp = pendingFingerprint {
|
||||
TrustCardView(
|
||||
fingerprint: fp,
|
||||
@@ -579,21 +564,13 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
||||
// longer contains the client-queue wait...
|
||||
// host/network split — drained by the 1 s stats tick).
|
||||
split.recordReceipt(
|
||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||
// ...which is measured as its own term instead (receipt→pull, both
|
||||
// client-local).
|
||||
queue.record(
|
||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
||||
offsetNs: 0)
|
||||
},
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
@@ -610,8 +587,7 @@ struct ContentView: View {
|
||||
},
|
||||
endToEndMeter: model.endToEnd,
|
||||
decodeMeter: model.decodeStage,
|
||||
displayMeter: model.displayStage,
|
||||
presentFloorMeter: model.presentFloor
|
||||
displayMeter: model.displayStage
|
||||
)
|
||||
.overlay(alignment: placement.alignment) {
|
||||
// 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)
|
||||
// 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) —
|
||||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
||||
// HUD) so the glyph stays legible over a bright frame.
|
||||
//
|
||||
// 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.
|
||||
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||
// sole touch disconnect path in those tiers.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Floating glass disc over the frame (26+, material fallback).
|
||||
// interactive: the disc IS the tap target, so the glass reacts
|
||||
// to press.
|
||||
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||
// over the frame (26+, material fallback). interactive: the disc
|
||||
// IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
@@ -689,12 +656,6 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.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
|
||||
|
||||
@@ -102,26 +102,6 @@ final class SessionModel: ObservableObject {
|
||||
@Published var decodeValid = false
|
||||
@Published var displayP50Ms = 0.0
|
||||
@Published var displayValid = false
|
||||
/// Client-queue wait: core reassembly receipt → the pump's pull (`AccessUnit.pulledNs −
|
||||
/// receivedNs`, ABI v9 receipt split — the 2026-07 two-pair investigation). ~0 on a healthy
|
||||
/// stream; a persistent value is a client-side standing backlog that used to hide inside
|
||||
/// "network". Shown in the detailed tier only when it says something (≥ ~2 ms).
|
||||
@Published var clientQueueP50Ms = 0.0
|
||||
@Published var clientQueueValid = false
|
||||
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
|
||||
/// engine's vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
||||
/// 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
|
||||
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||
@Published var lostFrames = 0
|
||||
@@ -153,12 +133,6 @@ final class SessionModel: ObservableObject {
|
||||
let endToEnd = LatencyMeter()
|
||||
let decodeStage = LatencyMeter()
|
||||
let displayStage = LatencyMeter()
|
||||
/// Client-queue sampler (see `clientQueueP50Ms`) — fed per AU by the stream view's onFrame,
|
||||
/// drained by the same 1 s tick as the stage meters.
|
||||
let clientQueue = LatencyMeter()
|
||||
/// The OS present floor sampler (see `osFloorP50Ms`) — fed one sample per display-link
|
||||
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
|
||||
let presentFloor = LatencyMeter()
|
||||
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||
private var lastFramesDropped: UInt64 = 0
|
||||
private var statsTimer: Timer?
|
||||
@@ -498,8 +472,6 @@ final class SessionModel: ObservableObject {
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
mouseCaptured = false
|
||||
@@ -683,31 +655,15 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
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,
|
||||
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||
// upstream (host capture/encode or the network).
|
||||
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(
|
||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -715,11 +671,7 @@ final class SessionModel: ObservableObject {
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -1,
|
||||
self.displayValid ? self.displayP50Ms : -1,
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
||||
lost)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,7 @@ struct StreamHUDView: View {
|
||||
private var compactLine: String {
|
||||
var parts = ["\(model.fps) fps"]
|
||||
if model.endToEndValid {
|
||||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
||||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
||||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
||||
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||
} else if model.hostNetworkValid {
|
||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||
}
|
||||
@@ -88,46 +86,24 @@ struct StreamHUDView: View {
|
||||
}
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
||||
// 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)")")
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew handshake.
|
||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
// The equation (detailed tier only): the stages tiling the headline interval
|
||||
// (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
|
||||
// into host + network (phase 2); an old host keeps the combined term. The
|
||||
// display term is floor-shaved like the headline, so the equation still sums.
|
||||
// into host + network (phase 2); an old host keeps the combined term.
|
||||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||
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))
|
||||
.foregroundStyle(.secondary)
|
||||
} 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))
|
||||
.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 {
|
||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||
|
||||
@@ -40,9 +40,7 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -290,19 +288,11 @@ struct GamepadSettingsView: View {
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||
+ "from the next session.",
|
||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||
) { 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 },
|
||||
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||
options: SettingsOptions.presenters, current: presenter
|
||||
) { presenter = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
|
||||
@@ -8,10 +8,7 @@ import SwiftUI
|
||||
/// 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.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
// The 2026-07 revamp's map: General = session/app behavior, Display = everything about the
|
||||
// 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
|
||||
case general, display, audio, controllers, advanced, about
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@@ -19,9 +16,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .input: return "Input"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
@@ -30,9 +27,9 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .input: return "keyboard"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,31 +37,28 @@ enum SettingsOptions {
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||
/// PUNKTFUNK_PRESENTER debug env lever.
|
||||
static let presentPriorities: [(label: String, tag: String)] = [
|
||||
("Lowest latency", "latency"),
|
||||
("Smoothness", "smooth"),
|
||||
]
|
||||
static let presentPriorityDefault = "latency"
|
||||
|
||||
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
||||
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
||||
/// 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),
|
||||
("1 frame (\(hint(1)))", 1),
|
||||
("2 frames (\(hint(2)))", 2),
|
||||
("3 frames (\(hint(3)))", 3),
|
||||
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||
static var presenters: [(label: String, tag: String)] {
|
||||
var options: [(label: String, tag: String)] = [
|
||||
("Stage 2", "stage2"),
|
||||
("Stage 3", "stage3"),
|
||||
]
|
||||
#if DEBUG
|
||||
options.append(("Stage 1 (debug)", "stage1"))
|
||||
#endif
|
||||
return options
|
||||
}
|
||||
|
||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS and
|
||||
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
|
||||
/// this so an untouched picker shows what actually runs.
|
||||
static var presenterDefault: String {
|
||||
#if os(macOS)
|
||||
"stage2"
|
||||
#else
|
||||
"stage3"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 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
|
||||
// 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)
|
||||
import CoreHaptics
|
||||
@@ -18,23 +8,18 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - Display: Resolution
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
@ViewBuilder var resolutionSection: some View {
|
||||
Section("Resolution") {
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS) || os(macOS)
|
||||
// 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.
|
||||
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
|
||||
#if os(iOS)
|
||||
iosResolutionWheel
|
||||
@@ -47,19 +32,75 @@ extension SettingsView {
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.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("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#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)
|
||||
// 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
|
||||
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
|
||||
@@ -78,11 +119,6 @@ extension SettingsView {
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.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
|
||||
|
||||
// MARK: - Display: Quality
|
||||
|
||||
@ViewBuilder var qualitySection: some View {
|
||||
Section("Quality") {
|
||||
#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.
|
||||
@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 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
@@ -256,228 +233,26 @@ extension SettingsView {
|
||||
}
|
||||
#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)
|
||||
}
|
||||
#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 {
|
||||
Section {
|
||||
described("The speaker layout requested from the host.") {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
described("Host audio plays through this device; System default follows your "
|
||||
+ "Mac's output changes.") {
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
}
|
||||
#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)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
@@ -493,27 +268,265 @@ extension SettingsView {
|
||||
// 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).
|
||||
if micChannelCount > 1 {
|
||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text("Applies from the next session.")
|
||||
Text(Self.audioFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.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 {
|
||||
Section {
|
||||
@@ -525,37 +538,24 @@ extension SettingsView {
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
||||
+ "recently connected.") {
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
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) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
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
|
||||
#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
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
@@ -565,9 +565,22 @@ extension SettingsView {
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text("Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
// 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))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,31 +9,6 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
|
||||
/// 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 maxSliderKbps = 3_000_000.0
|
||||
|
||||
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
||||
static let bitrateFooter =
|
||||
"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 "
|
||||
@@ -79,27 +53,55 @@ extension SettingsView {
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
static var statisticsDescription: String {
|
||||
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown."
|
||||
static var statisticsFooter: String {
|
||||
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||
+ "breakdown."
|
||||
#if os(macOS)
|
||||
return base + " ⌃⌥⇧S cycles the tiers any time."
|
||||
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||
#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
|
||||
return base
|
||||
#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
|
||||
|
||||
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
||||
static let controllersFooter =
|
||||
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
||||
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
||||
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
||||
+ "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`).
|
||||
var controllerOptions: [(label: String, tag: String)] {
|
||||
SettingsOptions.controllerOptions(gamepads)
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
// 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).
|
||||
//
|
||||
// Navigation differs per platform, but all three follow the same category map (General =
|
||||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
||||
// About — see SettingsCategory): macOS uses a tabbed preferences window; iOS/iPadOS uses an
|
||||
// adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an 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
|
||||
// focus-native pushed-picker layout in the same order. The individual sections
|
||||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
||||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
||||
// per-field `described` caption idiom) in SettingsView+Support.swift.
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -35,9 +33,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
#endif
|
||||
@@ -124,32 +120,27 @@ struct SettingsView: View {
|
||||
|
||||
#if os(macOS)
|
||||
private var macBody: some View {
|
||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
||||
// picture (resolution lives here), Input = keyboard & mouse.
|
||||
TabView {
|
||||
Form {
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
streamModeSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
Form {
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
vsyncSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Display", systemImage: "display") }
|
||||
|
||||
Form {
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Input", systemImage: "keyboard") }
|
||||
|
||||
Form {
|
||||
audioSection
|
||||
}
|
||||
@@ -177,10 +168,16 @@ struct SettingsView: View {
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||
|
||||
Form {
|
||||
experimentalSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||
|
||||
AcknowledgementsView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 500, height: 520)
|
||||
.frame(width: 480, height: 460)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -255,31 +252,26 @@ struct SettingsView: View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
sessionSection
|
||||
overlaySection
|
||||
librarySection
|
||||
streamModeSection
|
||||
pointerSection
|
||||
inputSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
keepAliveSection // iOS-only content; empty on tvOS
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
resolutionSection
|
||||
qualitySection
|
||||
presentationSection
|
||||
hostOutputSection
|
||||
presenterSection
|
||||
hdrSection
|
||||
vrrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .input:
|
||||
Form {
|
||||
pointerSection
|
||||
inputSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Input")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
@@ -290,6 +282,11 @@ struct SettingsView: View {
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// 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
|
||||
@@ -335,16 +332,6 @@ struct SettingsView: View {
|
||||
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 {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -357,9 +344,6 @@ struct SettingsView: View {
|
||||
if !options.contains(where: { $0.tag == currentTag }) {
|
||||
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 {
|
||||
VStack(spacing: 16) {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
@@ -371,41 +355,37 @@ struct SettingsView: View {
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
TVSelectionRow(
|
||||
title: "Audio channels",
|
||||
options: SettingsOptions.audioChannels,
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: SettingsOptions.compositors,
|
||||
selection: $compositor)
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: SettingsOptions.presenters,
|
||||
selection: $presenter)
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
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(
|
||||
title: "Auto-wake on connect",
|
||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
||||
+ "it before streaming.")
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "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(
|
||||
title: "Statistics overlay",
|
||||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||
@@ -425,7 +405,11 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Gamepad-optimized browsing",
|
||||
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() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
|
||||
/// decrypted — `PunktfunkFrame.received_ns`, ABI v9) — the **received** measurement point of
|
||||
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
|
||||
/// pre-decode hand-off wait into the network term, which is how the 2026-07 two-pair
|
||||
/// standing-latency plateau hid as "network". The decode stage is `decodedNs - receivedNs`,
|
||||
/// both client-local (no skew offset applies).
|
||||
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
|
||||
/// — the **received** measurement point of design/stats-unification.md. The decode stage is
|
||||
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant this pull returned. `pulledNs - receivedNs` is the
|
||||
/// client-queue wait (kernel hand-off + FrameChannel dwell) — the term the HUD splits out
|
||||
/// so a client-side standing backlog can never masquerade as network latency again.
|
||||
public let pulledNs: Int64
|
||||
|
||||
/// `pulledNs` defaults to `receivedNs` (zero queue wait) for callers with no pull instant —
|
||||
/// the synthetic probe AUs and decode tests, where the split is meaningless.
|
||||
public init(
|
||||
data: Data, ptsNs: UInt64, frameIndex: UInt32, flags: UInt32,
|
||||
receivedNs: Int64, pulledNs: Int64? = nil
|
||||
) {
|
||||
self.data = data
|
||||
self.ptsNs = ptsNs
|
||||
self.frameIndex = frameIndex
|
||||
self.flags = flags
|
||||
self.receivedNs = receivedNs
|
||||
self.pulledNs = pulledNs ?? receivedNs
|
||||
}
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
@@ -683,16 +662,11 @@ public final class PunktfunkConnection {
|
||||
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
|
||||
var ts = timespec()
|
||||
clock_gettime(CLOCK_REALTIME, &ts)
|
||||
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
// Receipt = the core's reassembly-completion stamp (ABI v9); the pull instant is
|
||||
// kept separately so the client-queue wait is its own measured term. 0 would mean a
|
||||
// pre-v9 core — impossible here (core and Kit ship in one binary), but fall back to
|
||||
// the pull instant rather than record a 1970 receipt.
|
||||
let receivedNs = frame.received_ns > 0 ? Int64(frame.received_ns) : pulledNs
|
||||
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
||||
receivedNs: receivedNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
|
||||
@@ -57,8 +57,30 @@ public enum BrandFont {
|
||||
}
|
||||
}
|
||||
|
||||
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
||||
// extension links Shared alone and must render the same purple.
|
||||
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
|
||||
}()
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
|
||||
@@ -602,36 +602,6 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
#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;
|
||||
/// `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
|
||||
@@ -647,15 +617,10 @@ public final class MetalVideoPresenter {
|
||||
/// 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
|
||||
/// (`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
|
||||
public func render(
|
||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||
@@ -709,8 +674,7 @@ public final class MetalVideoPresenter {
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// 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.
|
||||
keepAlive: [luma, chroma, pixelBuffer]
|
||||
@@ -729,7 +693,6 @@ public final class MetalVideoPresenter {
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
@@ -779,8 +742,7 @@ public final class MetalVideoPresenter {
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
// 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).
|
||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||
@@ -907,17 +869,9 @@ public final class MetalVideoPresenter {
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
///
|
||||
/// `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(
|
||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||
@@ -930,17 +884,11 @@ public final class MetalVideoPresenter {
|
||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? 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 DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#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(),
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// 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
|
||||
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
||||
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
||||
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
||||
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
||||
// The views own the platform bits — capture, window/scale tracking, and constructing the
|
||||
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink) —
|
||||
// and delegate the shared presenter lifecycle here.
|
||||
// (explicit VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's
|
||||
// CADisplayLink) is the default — glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
|
||||
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer)
|
||||
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits — capture,
|
||||
// window/scale tracking, and constructing the display link — and delegate the shared presenter
|
||||
// lifecycle here.
|
||||
//
|
||||
// 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) }
|
||||
}
|
||||
|
||||
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
|
||||
/// pacing (`PresentPacing` — see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
|
||||
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
|
||||
/// CAMetalDisplayLink-vended drawables (deadline pacing — iOS/tvOS only; see `PresentPacing`'s
|
||||
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
|
||||
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
|
||||
/// glass-gated present pacing (`PresentPacing` — see Stage2Pipeline for the tradeoff, and why
|
||||
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
|
||||
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
|
||||
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
|
||||
enum PresenterChoice: Equatable {
|
||||
case stage1
|
||||
case stage2
|
||||
case stage3
|
||||
case stage4
|
||||
|
||||
/// 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)
|
||||
@@ -54,92 +50,36 @@ enum PresenterChoice: Equatable {
|
||||
/// 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
|
||||
/// (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
|
||||
/// 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.
|
||||
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : nil
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
case "stage4":
|
||||
#if os(macOS)
|
||||
return nil
|
||||
#else
|
||||
return .stage4
|
||||
#endif
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
||||
///
|
||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
||||
/// (`displaySyncEnabled` is macOS-only API), and at stream rate ≈ panel rate — an Apple TV's
|
||||
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
|
||||
/// steering the panel to the stream — that queue's depth is STICKY: one burst fills it and,
|
||||
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
|
||||
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
|
||||
/// read ~30 ms display on arrival (~3 refreshes of queue), 22–28 ms glass-gated at depth 2
|
||||
/// (a standing queue of 2 — the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
|
||||
/// 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.
|
||||
/// 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
|
||||
/// near the stream rate — an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
|
||||
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
|
||||
/// makes the COMMON case — arrival pacing pins the queue at ~`maximumDrawableCount` and every
|
||||
/// frame rides ~2–3 refreshes of it (measured: ~50 ms on Apple TV; 23–30 ms at 120 Hz on
|
||||
/// ProMotion iPads — the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
|
||||
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. 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 {
|
||||
#if os(iOS) || os(tvOS)
|
||||
.stage4
|
||||
#if os(tvOS) || os(iOS)
|
||||
.stage3
|
||||
#else
|
||||
.stage2
|
||||
#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` ∈ 1…3;
|
||||
/// 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 {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
@@ -159,7 +99,6 @@ final class SessionPresenter {
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
if choice == .stage4 { return .deadline }
|
||||
if choice == .stage3 { return .glass }
|
||||
#if os(macOS)
|
||||
if explicit == nil, codec == .pyrowave { return .glass }
|
||||
@@ -167,27 +106,31 @@ final class SessionPresenter {
|
||||
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
|
||||
/// out + one queued, predicted ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
|
||||
/// 22–28 ms vs depth 1's 14: any second gate slot becomes a STANDING queue — a burst fills
|
||||
/// it, and with presents and latches then running at the same rate the occupancy never
|
||||
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
|
||||
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
|
||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||
/// - iOS/iPadOS: 2 — one flip scanning out plus one queued for the next latch. A decoded
|
||||
/// frame presents immediately (the gate is open in steady state) and latches the very next
|
||||
/// vsync, so the present cadence never serializes on the on-glass callback's own latency —
|
||||
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
|
||||
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
|
||||
/// FIFO saturation (23–30 ms) stays gone.
|
||||
/// - tvOS: 1 — the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
|
||||
/// 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.
|
||||
///
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
||||
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
||||
/// serialization is its point. Internal (not private) for unit tests.
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other
|
||||
/// presenter env levers. Internal (not private) for unit tests.
|
||||
static func gateDepth(env: String?) -> Int {
|
||||
#if os(macOS)
|
||||
return 1
|
||||
#else
|
||||
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
|
||||
#if os(tvOS)
|
||||
return 1
|
||||
#else
|
||||
return 2
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -224,7 +167,6 @@ final class SessionPresenter {
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil,
|
||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
@@ -233,50 +175,31 @@ final class SessionPresenter {
|
||||
stop()
|
||||
self.connection = connection
|
||||
|
||||
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
||||
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
||||
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
||||
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
||||
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
||||
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
||||
// (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).
|
||||
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
|
||||
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
|
||||
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
|
||||
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
|
||||
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
|
||||
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
|
||||
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
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"],
|
||||
allowStage1: allowStage1)
|
||||
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,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
pacing: pacing,
|
||||
pacing: Self.pacing(
|
||||
for: choice, explicit: explicit, codec: connection.videoCodec),
|
||||
gateDepth: Self.gateDepth(
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
|
||||
storePolicy: priority.storePolicy,
|
||||
vsyncPaced: vsyncPaced) {
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
@@ -293,19 +216,14 @@ final class SessionPresenter {
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||
// 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
|
||||
self?.stage2?.renderTick(
|
||||
targetMediaTime: link.targetTimestamp,
|
||||
period: link.targetTimestamp - link.timestamp)
|
||||
}
|
||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
stage2Link = link
|
||||
let proxy = DisplayLinkProxy { [weak self] link in
|
||||
self?.stage2?.renderTick(
|
||||
targetMediaTime: link.targetTimestamp,
|
||||
period: link.targetTimestamp - link.timestamp)
|
||||
}
|
||||
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
stage2Link = link
|
||||
syncFrameRate(hz: connection.currentMode().refreshHz)
|
||||
pipeline.start(
|
||||
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
|
||||
@@ -333,12 +251,7 @@ final class SessionPresenter {
|
||||
/// 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.
|
||||
private func syncFrameRate(hz: UInt32) {
|
||||
guard hz > 0 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 }
|
||||
guard hz > 0, let link = stage2Link else { return }
|
||||
let hzF = Float(hz)
|
||||
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
|
||||
#if os(macOS)
|
||||
|
||||
@@ -18,14 +18,11 @@
|
||||
// – 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
|
||||
// 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
|
||||
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
|
||||
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
|
||||
// CAMetalDisplayLink-vended drawables the moment a frame decodes — deadline pacing, where the
|
||||
// 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.
|
||||
// • Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
|
||||
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
|
||||
// frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
|
||||
// drawables (the gate depth — see PresentPacing + SessionPresenter.gateDepth) so the layer's
|
||||
// FIFO image queue can never saturate — see PresentPacing's doc for the full rationale.
|
||||
// • 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 capture→on-glass + decode and
|
||||
@@ -44,7 +41,6 @@ import Foundation
|
||||
import Metal
|
||||
import PunktfunkShared
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
/// 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
|
||||
@@ -52,127 +48,33 @@ import os
|
||||
/// stdout is the cheapest reliable capture channel.
|
||||
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
|
||||
|
||||
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") — the
|
||||
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// 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 {
|
||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||
private final class ReadyRing: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let capacity: Int // 1 = newest-wins semantics
|
||||
private let isFifo: Bool
|
||||
private var frames: [Frame] = []
|
||||
private var prerolled = false
|
||||
/// Submissions since the last `drainSubmitted` — the decode rate for the pf-present line.
|
||||
private var frame: ReadyFrame?
|
||||
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
||||
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
||||
private var submitted = 0
|
||||
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
||||
/// runs-dry that re-armed preroll.
|
||||
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: ReadyFrame) {
|
||||
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
||||
}
|
||||
|
||||
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 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = submitted
|
||||
submitted = 0
|
||||
return n
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
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() -> ReadyFrame? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
let f = frame; frame = nil; return f
|
||||
}
|
||||
|
||||
func take() -> Frame? {
|
||||
/// 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()
|
||||
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]
|
||||
}
|
||||
if frame == nil { frame = f }
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -215,24 +117,13 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and
|
||||
/// the full-queue regime is where host↔panel clock drift turns into periodic repeats/drops (the
|
||||
/// "fixed-interval" jitter reports).
|
||||
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
|
||||
/// undisplayed drawables in flight (`PresentGate`; depth 1 — see `SessionPresenter.gateDepth`
|
||||
/// for why deeper is a regression). The render thread presents only while a gate slot is free
|
||||
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
|
||||
/// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
|
||||
/// undisplayed drawables in flight (`PresentGate`; the depth — 1 or 2 — is per-platform, see
|
||||
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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.
|
||||
/// explicit, correct frame drops.
|
||||
///
|
||||
/// 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
|
||||
@@ -241,122 +132,6 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
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 vend→glass
|
||||
/// 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.
|
||||
@@ -431,20 +206,10 @@ final class PresentGate: @unchecked Sendable {
|
||||
private final class PresentDebugStats: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
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 lastGlassNs: Int64 = 0
|
||||
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
|
||||
/// 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).
|
||||
@@ -458,14 +223,6 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
/// is normal, it just shows the gate working.
|
||||
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) {
|
||||
lock.lock()
|
||||
if rendered {
|
||||
@@ -479,59 +236,40 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func presented(atNs: Int64?, issuedNs: Int64) {
|
||||
func presented(atNs: Int64?) {
|
||||
lock.lock()
|
||||
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
|
||||
if let atNs {
|
||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||
lastGlassNs = atNs
|
||||
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
||||
} else {
|
||||
dropped += 1
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
||||
lock.lock()
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else { lock.unlock(); return }
|
||||
last = now
|
||||
let decoded = ring.drainSubmitted()
|
||||
let smoothing = ring.drainSmoothing()
|
||||
let deltas = glassDeltasMs.sorted()
|
||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||
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 line = String(
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
||||
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
|
||||
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
||||
+ "vendLeadMs p50=%.2f max=%.2f",
|
||||
decoded, ok, failed, empty, gated, noDrawable, dropped,
|
||||
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
|
||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
|
||||
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
|
||||
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
|
||||
maxRenderMs = 0
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
glassDeltasMs.removeAll(keepingCapacity: true)
|
||||
latchMs.removeAll(keepingCapacity: true)
|
||||
vendLeadMs.removeAll(keepingCapacity: true)
|
||||
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)
|
||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||
}
|
||||
print(line)
|
||||
fflush(stdout) // stdout is a pipe when captured — flush per line or nothing shows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,27 +296,18 @@ private final class DecodeReport: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public final class Stage2Pipeline {
|
||||
private let ring: FrameStore<ReadyFrame>
|
||||
private let ring = ReadyRing()
|
||||
private let presenter: MetalVideoPresenter
|
||||
private let decoder: VideoDecoder
|
||||
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
||||
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
||||
/// SessionPresenter resolves it per session (see PresentPacing).
|
||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
||||
private let pacing: PresentPacing
|
||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||
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 decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
/// The measured OS present floor (deadline pacing only): each link update's vend→glass 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()
|
||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||
/// binds the live connection + arming flag (see DecodeReport).
|
||||
@@ -609,9 +338,6 @@ public final class Stage2Pipeline {
|
||||
private let vsyncClock = VsyncClock()
|
||||
private let renderStopped = DispatchSemaphore(value: 0)
|
||||
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.
|
||||
public var layer: CAMetalLayer { presenter.layer }
|
||||
@@ -628,22 +354,16 @@ public final class Stage2Pipeline {
|
||||
endToEndMeter: LatencyMeter?,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil,
|
||||
pacing: PresentPacing = .arrival,
|
||||
gateDepth: Int = 1,
|
||||
storePolicy: FrameStorePolicy = .newestWins,
|
||||
vsyncPaced: Bool = false
|
||||
gateDepth: Int = 1
|
||||
) {
|
||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||
self.presenter = presenter
|
||||
self.pacing = pacing
|
||||
self.gateDepth = gateDepth
|
||||
self.vsyncPaced = vsyncPaced
|
||||
self.ring = FrameStore(policy: storePolicy)
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
@@ -818,17 +538,6 @@ public final class Stage2Pipeline {
|
||||
pumpJoinable = true
|
||||
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/
|
||||
// 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
|
||||
@@ -843,21 +552,16 @@ public final class Stage2Pipeline {
|
||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||
// Resolved once per session.
|
||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
||||
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling — the FIFO store must drain
|
||||
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
|
||||
let vsyncPaced = vsyncPaced
|
||||
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|
||||
let vsyncEnabled = presentMode == "vsync"
|
||||
|| (presentMode != "immediate"
|
||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||
let debugStats = presentDebug ? PresentDebugStats() : nil
|
||||
let vsyncClock = vsyncClock
|
||||
// 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`.
|
||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||
let renderThread = Thread {
|
||||
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`):
|
||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||
// 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)
|
||||
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
|
||||
// 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
|
||||
@@ -896,7 +591,6 @@ public final class Stage2Pipeline {
|
||||
let presentAt = vsyncEnabled
|
||||
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
|
||||
let renderStarted = CACurrentMediaTime()
|
||||
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
// 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.
|
||||
@@ -914,7 +608,7 @@ public final class Stage2Pipeline {
|
||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||
// so no skew offset applies.
|
||||
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
|
||||
// PyroWave Metal planes — the ring, pacing and meters are agnostic to which.
|
||||
@@ -933,8 +627,6 @@ public final class Stage2Pipeline {
|
||||
if !rendered {
|
||||
gate?.release() // no present registered — its handler will never fire
|
||||
ring.putBack(frame)
|
||||
} else if vsyncPaced, let presentAt {
|
||||
lastPresentTarget = presentAt // this vsync's slot is now taken
|
||||
}
|
||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||
} }
|
||||
@@ -945,168 +637,16 @@ public final class Stage2Pipeline {
|
||||
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)
|
||||
/// 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
|
||||
/// 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
|
||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
||||
/// replacement frame.
|
||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||
vsyncClock.set(target: targetMediaTime, period: period)
|
||||
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
|
||||
/// `MetalVideoPresenter.setDrawableTarget`).
|
||||
public func setDrawableTarget(_ size: CGSize) {
|
||||
@@ -1169,7 +709,7 @@ public final class Stage2Pipeline {
|
||||
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||
private static func makePyroWavePump(
|
||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
||||
device: MTLDevice, queue: MTLCommandQueue,
|
||||
decodeMeter: LatencyMeter?,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
@@ -1213,9 +753,7 @@ public final class Stage2Pipeline {
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
||||
let receivedNs = au.pulledNs
|
||||
let receivedNs = au.receivedNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
|
||||
@@ -65,21 +65,19 @@ public enum Stage444Probe {
|
||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||
|
||||
var produced: OSType = 0
|
||||
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
||||
// 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 done = DispatchSemaphore(value: 0)
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session, sampleBuffer: sample,
|
||||
flags: [], infoFlagsOut: nil
|
||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
||||
) { status, _, imageBuffer, _, _ in
|
||||
if status == noErr, let imageBuffer {
|
||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||
}
|
||||
done.signal()
|
||||
}
|
||||
guard status == noErr else { return false }
|
||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||
_ = done.wait(timeout: .now() + 1.0)
|
||||
return produced == want || produced == fullRangeSibling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
|
||||
public struct ReadyFrame: @unchecked Sendable {
|
||||
/// Host capture clock (the AU's pts), in nanoseconds.
|
||||
public let ptsNs: UInt64
|
||||
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds — the decode stage's start
|
||||
/// point. (Named for its historical role; since the ABI v9 receipt split the true
|
||||
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receipt→pull is the HUD's own
|
||||
/// client-queue term.) 0 when unknown (a caller that didn't stamp) — the decode-stage meter
|
||||
/// then drops the sample via its sanity guard.
|
||||
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
|
||||
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
|
||||
/// didn't stamp receipt) — the decode-stage meter then drops the sample via its sanity guard.
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
var infoOut = VTDecodeInfoFlags()
|
||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
|
||||
// receipt: both consumers — the decode-stage meter and the ABR decode signal — are
|
||||
// specified from the pull, and the receipt→pull wait is the HUD's separate client-queue
|
||||
// term (see AccessUnit.pulledNs).
|
||||
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
|
||||
@@ -93,7 +93,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let presentFloorMeter: LatencyMeter?
|
||||
|
||||
/// `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
|
||||
@@ -116,8 +115,7 @@ public struct StreamView: NSViewRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -130,7 +128,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeNSView(context: Context) -> StreamLayerView {
|
||||
@@ -141,7 +138,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.presentFloorMeter = presentFloorMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -155,7 +151,6 @@ public struct StreamView: NSViewRepresentable {
|
||||
view.endToEndMeter = endToEndMeter
|
||||
view.decodeMeter = decodeMeter
|
||||
view.displayMeter = displayMeter
|
||||
view.presentFloorMeter = presentFloorMeter
|
||||
view.onResizeTarget = onResizeTarget
|
||||
view.onDecodedSize = onDecodedSize
|
||||
// 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 decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
var presentFloorMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -667,7 +661,6 @@ public final class StreamLayerView: NSView {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
|
||||
@@ -61,7 +61,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let decodeMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let presentFloorMeter: LatencyMeter?
|
||||
|
||||
/// `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
|
||||
@@ -78,8 +77,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
decodeMeter: LatencyMeter? = nil,
|
||||
displayMeter: LatencyMeter? = nil,
|
||||
presentFloorMeter: LatencyMeter? = nil
|
||||
displayMeter: LatencyMeter? = nil
|
||||
) {
|
||||
self.connection = connection
|
||||
self.captureEnabled = captureEnabled
|
||||
@@ -91,7 +89,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
self.endToEndMeter = endToEndMeter
|
||||
self.decodeMeter = decodeMeter
|
||||
self.displayMeter = displayMeter
|
||||
self.presentFloorMeter = presentFloorMeter
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||
@@ -101,7 +98,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.presentFloorMeter = presentFloorMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
@@ -114,7 +110,6 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
controller.endToEndMeter = endToEndMeter
|
||||
controller.decodeMeter = decodeMeter
|
||||
controller.displayMeter = displayMeter
|
||||
controller.presentFloorMeter = presentFloorMeter
|
||||
controller.onResizeTarget = onResizeTarget
|
||||
controller.onDecodedSize = onDecodedSize
|
||||
if controller.connection !== connection {
|
||||
@@ -150,7 +145,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
var endToEndMeter: LatencyMeter?
|
||||
var decodeMeter: LatencyMeter?
|
||||
var displayMeter: LatencyMeter?
|
||||
var presentFloorMeter: LatencyMeter?
|
||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||
private let presenter = SessionPresenter()
|
||||
@@ -412,7 +406,6 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
endToEndMeter: endToEndMeter,
|
||||
decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
presentFloorMeter: presentFloorMeter,
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
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,21 +50,12 @@ public enum DefaultsKey {
|
||||
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||
public static let micChannel = "punktfunk.micChannel"
|
||||
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
||||
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
||||
/// `presentPriority` below; the stage ladder survives only as the
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
||||
/// value is documented, not mysterious.
|
||||
/// Which presenter runs a session: "stage2" (default — explicit decode + Metal present on
|
||||
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing — the experimental
|
||||
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
|
||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
|
||||
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 1…3.
|
||||
/// 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
|
||||
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||
|
||||
@@ -4,10 +4,9 @@ import XCTest
|
||||
import QuartzCore
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
|
||||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
||||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
||||
/// stage-4 gate), and the per-platform glass-gate depth.
|
||||
/// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
|
||||
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
|
||||
/// release-build stage-1 gate), and the per-platform glass-gate depth.
|
||||
final class PresentPacingTests: XCTestCase {
|
||||
// MARK: - PresentGate
|
||||
|
||||
@@ -22,10 +21,8 @@ final class PresentPacingTests: XCTestCase {
|
||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||
}
|
||||
|
||||
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung — no longer a default; see
|
||||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
|
||||
/// behind the flip scanning out — the bound only bites at the THIRD. One release (a glass
|
||||
/// callback) reopens exactly one slot.
|
||||
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out — the
|
||||
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
|
||||
func testGateDepthTwoAdmitsTwoInFlightPresents() {
|
||||
let gate = PresentGate(capacity: 2)
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
@@ -75,143 +72,16 @@ final class PresentPacingTests: XCTestCase {
|
||||
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 intent→store 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
|
||||
|
||||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
||||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
||||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
||||
/// No selection / garbage falls back to it.
|
||||
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
|
||||
/// tvOS — arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
|
||||
/// (sync-off presents don't queue). No selection / garbage falls back to it.
|
||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||
#if os(iOS) || os(tvOS)
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
||||
#else
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||
#else
|
||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||||
@@ -236,29 +106,6 @@ final class PresentPacingTests: XCTestCase {
|
||||
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
|
||||
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
@@ -309,30 +156,25 @@ final class PresentPacingTests: XCTestCase {
|
||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||
XCTAssertEqual(
|
||||
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: - Glass-gate depth
|
||||
|
||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
||||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
||||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
||||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
|
||||
/// Out-of-range/garbage values are ignored.
|
||||
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
|
||||
/// queued — the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
|
||||
/// there is the swapID-panic mitigation — strict serialization is its point, so the env
|
||||
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
|
||||
/// values are ignored.
|
||||
func testGateDepthPlatformDefaultsAndEnvOverride() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 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
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: nil), 1,
|
||||
"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")
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
|
||||
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
|
||||
#endif
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),
|
||||
|
||||
@@ -55,11 +55,6 @@ pub struct AppModel {
|
||||
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||
/// run in the session binary, which has its own.
|
||||
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>,
|
||||
/// One session child at a time — connects while one runs are ignored.
|
||||
busy: bool,
|
||||
@@ -165,42 +160,6 @@ impl SimpleComponent for AppModel {
|
||||
}
|
||||
|
||||
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
|
||||
// matches it whenever such a pad connects).
|
||||
{
|
||||
@@ -238,7 +197,6 @@ impl SimpleComponent for AppModel {
|
||||
settings,
|
||||
identity,
|
||||
gamepad: init.gamepad,
|
||||
probes,
|
||||
hosts,
|
||||
busy: false,
|
||||
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
|
||||
// box is already booting while the dial times out, arm the wake-wait fallback
|
||||
// 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());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
}
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
self.wake_fallback = Some(req.clone());
|
||||
sender.input(AppMsg::Connect(req));
|
||||
}
|
||||
}
|
||||
@@ -484,7 +435,6 @@ impl SimpleComponent for AppModel {
|
||||
&self.window,
|
||||
self.settings.clone(),
|
||||
&self.gamepad,
|
||||
&self.probes.borrow(),
|
||||
move || {
|
||||
// The library toggle changes the saved cards' menu — re-render.
|
||||
let _ = hosts.send(HostsMsg::Refresh);
|
||||
|
||||
@@ -352,7 +352,6 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
@@ -535,34 +534,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
||||
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);
|
||||
}
|
||||
}
|
||||
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||
"pair" | "05-pair" => {
|
||||
|
||||
+103
-423
@@ -1,9 +1,5 @@
|
||||
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
|
||||
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
|
||||
//! 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.
|
||||
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||
//! capture behavior. Written back to disk when the dialog closes.
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
@@ -44,31 +40,14 @@ const GAMEPADS: &[&str] = &[
|
||||
"steamdeck",
|
||||
];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
/// HEVC when either side can't do it.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
|
||||
const CODEC_LABELS: &[&str] = &[
|
||||
"Automatic",
|
||||
"HEVC (H.265)",
|
||||
"H.264 (AVC)",
|
||||
"AV1",
|
||||
"PyroWave (wired LAN)",
|
||||
];
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
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.
|
||||
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
|
||||
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
|
||||
/// 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.
|
||||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||
pub fn show(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
probes: &DeviceProbes,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) -> adw::PreferencesDialog {
|
||||
) {
|
||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||
// subpage onto it.
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
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 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
|
||||
// `match_window` flag), then the explicit sizes.
|
||||
let res_names: Vec<String> = std::iter::once("Native display".to_string())
|
||||
@@ -388,13 +297,10 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"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<_>>(),
|
||||
);
|
||||
{
|
||||
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
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
@@ -409,11 +315,9 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Refresh rate",
|
||||
"Native follows the monitor the window is on",
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// ---- Display: Quality ----
|
||||
let scale_names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
.map(|&s| render_scale_label(s))
|
||||
@@ -422,69 +326,13 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"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<_>>(),
|
||||
);
|
||||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||
bitrate_row.set_title("Bitrate");
|
||||
bitrate_row
|
||||
.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 ----
|
||||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||
let compositor_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -498,19 +346,19 @@ pub fn show(
|
||||
"gamescope",
|
||||
],
|
||||
);
|
||||
|
||||
// ---- General ----
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
.build();
|
||||
let wake_row = adw::SwitchRow::builder()
|
||||
.title("Auto-wake on connect")
|
||||
.subtitle(
|
||||
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
|
||||
off if hosts behind a VPN look offline when they aren't",
|
||||
)
|
||||
.build();
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Video decoder",
|
||||
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
|
||||
Vulkan Video on NVIDIA), falling back to software",
|
||||
&[
|
||||
"Automatic (hardware → software)",
|
||||
"Vulkan Video",
|
||||
"VAAPI",
|
||||
"Software",
|
||||
],
|
||||
);
|
||||
let stats_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -518,101 +366,20 @@ pub fn show(
|
||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||
&["Off", "Compact", "Normal", "Detailed"],
|
||||
);
|
||||
let library_row = adw::SwitchRow::builder()
|
||||
.title("Show game library")
|
||||
.subtitle(
|
||||
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
|
||||
and launch one directly. No extra host setup",
|
||||
)
|
||||
let fullscreen_row = adw::SwitchRow::builder()
|
||||
.title("Start streams in fullscreen")
|
||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||
.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 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 ----
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// 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
|
||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||
@@ -646,7 +413,7 @@ pub fn show(
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} 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<_>>(),
|
||||
);
|
||||
@@ -676,7 +443,7 @@ pub fn show(
|
||||
&dialog,
|
||||
inline,
|
||||
"Gamepad type",
|
||||
"The virtual pad on the host — Automatic matches your controller",
|
||||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||||
&[
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
@@ -686,8 +453,66 @@ pub fn show(
|
||||
"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 res_i = if s.match_window {
|
||||
@@ -700,7 +525,6 @@ pub fn show(
|
||||
.unwrap_or(0)
|
||||
};
|
||||
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);
|
||||
hz_row.set_selected(hz_i as u32);
|
||||
let scale_i = RENDER_SCALES
|
||||
@@ -716,8 +540,6 @@ pub fn show(
|
||||
.position(|&t| t == s.touch_mode)
|
||||
.unwrap_or(0);
|
||||
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
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -731,11 +553,8 @@ pub fn show(
|
||||
.unwrap_or(0);
|
||||
stats_row.set_selected(stats_i as u32);
|
||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||
wake_row.set_active(s.auto_wake);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
invert_row.set_active(s.invert_scroll);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
hdr_row.set_active(s.hdr_enabled);
|
||||
library_row.set_active(s.library_enabled);
|
||||
surround_row.set_selected(match s.audio_channels {
|
||||
6 => 1,
|
||||
@@ -744,104 +563,9 @@ pub fn show(
|
||||
});
|
||||
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
||||
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) ----
|
||||
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 & 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(>k::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.add(&page);
|
||||
dialog.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
@@ -856,40 +580,19 @@ pub fn show(
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
||||
// writing that back would silently erase it just by opening + closing the dialog.
|
||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
||||
// a listed one to begin with.
|
||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.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(
|
||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||
);
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.auto_wake = wake_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.hdr_enabled = hdr_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
@@ -902,7 +605,6 @@ pub fn show(
|
||||
on_closed();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
dialog
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -976,17 +678,6 @@ mod tests {
|
||||
assert_eq!(fired.get(), 1);
|
||||
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).
|
||||
row.widget()
|
||||
.downcast_ref::<adw::ActionRow>()
|
||||
@@ -1007,16 +698,5 @@ mod tests {
|
||||
combo.set_selected(0);
|
||||
assert_eq!(combo.selected(), 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -458,11 +458,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
),
|
||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||
}
|
||||
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
||||
// Frame every read on the control stream through the resumable reader, exactly as the client
|
||||
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
||||
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
||||
let mut recv = io::MsgReader::new(recv);
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
@@ -517,8 +513,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome =
|
||||
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
||||
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
tracing::info!(
|
||||
mode = ?welcome.mode,
|
||||
fec = ?welcome.fec,
|
||||
@@ -633,7 +629,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("Reconfigure write failed");
|
||||
return;
|
||||
}
|
||||
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| Reconfigured::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) if ack.accepted => {
|
||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||
}
|
||||
@@ -686,7 +685,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("SetBitrate write failed");
|
||||
return;
|
||||
}
|
||||
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| BitrateChanged::decode(&b))
|
||||
{
|
||||
Ok(Ok(ack)) => tracing::info!(
|
||||
applied_kbps = ack.bitrate_kbps,
|
||||
"BITRATE CHANGE acked by host"
|
||||
@@ -748,7 +750,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("ProbeRequest write failed");
|
||||
return;
|
||||
}
|
||||
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
||||
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||
Ok(Ok(r)) => r,
|
||||
other => {
|
||||
tracing::error!(?other, "bad ProbeResult");
|
||||
|
||||
@@ -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
|
||||
# binary.
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
|
||||
# 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-presenter = { path = "../../crates/pf-presenter" }
|
||||
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"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
@@ -158,7 +158,6 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
@@ -453,7 +452,6 @@ impl ServiceState {
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
|
||||
@@ -103,14 +103,6 @@ mod session_main {
|
||||
force_software: Arc<AtomicBool>,
|
||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
) -> 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`
|
||||
// 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
|
||||
@@ -173,7 +165,6 @@ mod session_main {
|
||||
// pump) pins one manually.
|
||||
display_hdr: None,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
@@ -268,66 +259,19 @@ mod session_main {
|
||||
)
|
||||
.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
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||
// the RADV knob (covers --connect and --browse).
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
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);
|
||||
}
|
||||
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
|
||||
// name) → the presenter's device selection, unless the user already forced one.
|
||||
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
|
||||
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
|
||||
let adapter = trust::Settings::load().adapter;
|
||||
if !adapter.is_empty() {
|
||||
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +373,6 @@ mod session_main {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -12,12 +12,6 @@ repository.workspace = true
|
||||
name = "punktfunk-client"
|
||||
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
|
||||
# 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")
|
||||
@@ -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
|
||||
# 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.
|
||||
#
|
||||
# `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 }
|
||||
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||
|
||||
# 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
|
||||
|
||||
@@ -57,28 +57,6 @@
|
||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
||||
</uap:VisualElements>
|
||||
</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>
|
||||
|
||||
<Capabilities>
|
||||
|
||||
@@ -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/
|
||||
# src/spawn.rs); Skia links statically and vulkan-1.dll is a GPU-driver component, so the session
|
||||
# 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) {
|
||||
$src = Join-Path $TargetDir $f
|
||||
if (-not (Test-Path $src)) { throw "missing build artifact '$f' in $TargetDir (did 'cargo build --release' run?)" }
|
||||
|
||||
@@ -36,9 +36,7 @@ pub(crate) fn initiate_waking(
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -274,7 +272,6 @@ fn connect_spawn(
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -294,13 +291,9 @@ fn connect_spawn(
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
}
|
||||
Some((_, false))
|
||||
if wake_on_fail && ctx2.settings.lock().unwrap().auto_wake =>
|
||||
{
|
||||
Some((_, false)) if wake_on_fail => {
|
||||
// The dial-first attempt to a non-advertising host failed — it
|
||||
// may genuinely be asleep. NOW wake and wait. Skipped entirely
|
||||
// when auto-wake is off: the wait is only worth showing if we
|
||||
// are actually sending magic packets to end it.
|
||||
// may genuinely be asleep. NOW wake and wait.
|
||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
||||
}
|
||||
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
|
||||
/// 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).
|
||||
/// `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(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Option<Target>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
@@ -341,21 +331,15 @@ pub(crate) fn open_console(
|
||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||
ctx.shared.stats_line.lock().unwrap().clear();
|
||||
ctx.shared.browse.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = target.clone() {
|
||||
*ctx.shared.target.lock().unwrap() = t;
|
||||
}
|
||||
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||
let fullscreen = ctx.settings.lock().unwrap().fullscreen_on_stream;
|
||||
set_status.call(String::new());
|
||||
set_screen.call(Screen::Connecting);
|
||||
|
||||
let shared = ctx.shared.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 = crate::spawn::spawn_browse(
|
||||
addr_port.as_ref().map(|(a, p)| (a.as_str(), *p)),
|
||||
fullscreen,
|
||||
child,
|
||||
move |event| {
|
||||
let spawned =
|
||||
crate::spawn::spawn_browse(&target.addr, target.port, fullscreen, child, move |event| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
match event {
|
||||
SpawnEvent::Ready => {
|
||||
@@ -373,8 +357,7 @@ pub(crate) fn open_console(
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
set_status.call(e);
|
||||
set_screen.call(Screen::Hosts);
|
||||
@@ -484,7 +467,6 @@ fn wake_and_connect(
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
|
||||
+130
-192
@@ -1,5 +1,5 @@
|
||||
//! 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.
|
||||
|
||||
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.
|
||||
const MENU_CONNECT: &str = "Connect";
|
||||
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_WAKE: &str = "Wake host";
|
||||
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
|
||||
/// 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_RENAME: &str = "Rename\u{2026}";
|
||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
|
||||
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
|
||||
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
|
||||
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
|
||||
///
|
||||
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
|
||||
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
|
||||
/// through a re-render.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_editor(
|
||||
fp: &str,
|
||||
initial_name: &str,
|
||||
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)>>,
|
||||
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||
/// 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
|
||||
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||
fn rename_editor(
|
||||
initial: &str,
|
||||
fp: String,
|
||||
live: HookRef<String>,
|
||||
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||
) -> Element {
|
||||
let commit = {
|
||||
let (fp, se) = (fp.to_string(), set_edit.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(),
|
||||
);
|
||||
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||
move || {
|
||||
let mut known = KnownHosts::load();
|
||||
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",
|
||||
// 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 draft = live.borrow();
|
||||
let name = draft.trim();
|
||||
if !name.is_empty() {
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.name = name.to_string();
|
||||
}
|
||||
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>| {
|
||||
vstack((
|
||||
text_block(label)
|
||||
.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 on_changed = {
|
||||
let live = live.clone();
|
||||
move |s: String| live.set(s)
|
||||
};
|
||||
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(
|
||||
vstack((
|
||||
field("Name", name0, "e.g. Living Room", name_draft),
|
||||
field("Address", addr0, "IP or hostname", addr_draft),
|
||||
field("Port", port0, "9777", port_draft),
|
||||
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),
|
||||
text_box(initial)
|
||||
.placeholder_text("Host name")
|
||||
.on_text_changed(on_changed),
|
||||
hstack((
|
||||
button("Save")
|
||||
.accent()
|
||||
@@ -304,7 +231,7 @@ fn edit_editor(
|
||||
.on_click(commit),
|
||||
button("Cancel")
|
||||
.subtle()
|
||||
.on_click(move || set_edit.call(None)),
|
||||
.on_click(move || set_rename.call(None)),
|
||||
))
|
||||
.spacing(4.0),
|
||||
))
|
||||
@@ -337,41 +264,16 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let rename = props.rename.clone();
|
||||
let set_forget = &props.set_forget;
|
||||
let set_rename = &props.set_rename;
|
||||
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
|
||||
// the target's fingerprint + initial name, so typing never round-trips through a
|
||||
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
|
||||
// changes (open, cancel, or switching to another host).
|
||||
let name_draft = cx.use_ref(String::new());
|
||||
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);
|
||||
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||
// TARGET changes (start, cancel, or a switch to another host).
|
||||
let rename_draft = cx.use_ref(String::new());
|
||||
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||
{
|
||||
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||
if *edit_seed.borrow() != active {
|
||||
let stored = active.as_ref().and_then(|fp| {
|
||||
KnownHosts::load()
|
||||
.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);
|
||||
if *rename_seed.borrow() != active {
|
||||
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||
rename_seed.set(active);
|
||||
}
|
||||
}
|
||||
let hover = Hover {
|
||||
@@ -412,51 +314,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
hstack({
|
||||
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add)
|
||||
.accent()
|
||||
.on_click({
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// 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();
|
||||
move || ss.call(Screen::Help)
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
actions.push(
|
||||
header_btn("Settings", Symbol::Setting)
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
actions
|
||||
})
|
||||
hstack((
|
||||
header_btn("Add host", Symbol::Add).accent().on_click({
|
||||
let sa = set_show_add.clone();
|
||||
move || sa.call(true)
|
||||
}),
|
||||
header_btn("Shortcuts", Symbol::Keyboard).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Help)
|
||||
}),
|
||||
header_btn("Settings", Symbol::Setting).on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
}),
|
||||
))
|
||||
.spacing(8.0)
|
||||
.grid_column(1)
|
||||
.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
|
||||
// being advertised right now shows as Online (and is deduped out of the discovery section).
|
||||
if !known.hosts.is_empty() {
|
||||
body.push(section("SAVED HOSTS"));
|
||||
let mut tiles: Vec<Element> = Vec::new();
|
||||
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) {
|
||||
let (fp, initial) = rename.clone().unwrap();
|
||||
tiles.push(edit_editor(
|
||||
&fp,
|
||||
tiles.push(rename_editor(
|
||||
&initial,
|
||||
name_draft.clone(),
|
||||
addr_draft.clone(),
|
||||
port_draft.clone(),
|
||||
mac_draft.clone(),
|
||||
clip_draft.clone(),
|
||||
fp,
|
||||
rename_draft.clone(),
|
||||
set_rename.clone(),
|
||||
));
|
||||
continue;
|
||||
@@ -539,12 +471,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
if library_enabled && k.paired {
|
||||
items.push(menu_item(MENU_LIBRARY));
|
||||
}
|
||||
if CONSOLE_UI_AVAILABLE && k.paired {
|
||||
items.push(menu_item(MENU_CONSOLE));
|
||||
}
|
||||
items.push(menu_item(MENU_SPEED));
|
||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||
if can_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_item(MENU_FORGET));
|
||||
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);
|
||||
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_SPEED => {
|
||||
*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_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()))),
|
||||
_ => {}
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
|
||||
// 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("general".to_string());
|
||||
let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string());
|
||||
// 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
|
||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||
|
||||
@@ -59,7 +59,6 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: target3.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
|
||||
+126
-321
@@ -1,15 +1,5 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! **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::{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.
|
||||
const DECODERS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic (GPU, fall back to CPU)"),
|
||||
("vulkan", "Hardware (Vulkan Video)"),
|
||||
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
|
||||
("vulkan", "Hardware (GPU / Vulkan Video)"),
|
||||
("software", "Software (CPU)"),
|
||||
];
|
||||
/// 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`).
|
||||
///
|
||||
/// The caption goes BELOW the control on purpose. An earlier revision put guidance only in
|
||||
/// hover tooltips because a paragraph *above* a control reads as that control's label — true,
|
||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
||||
/// 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
|
||||
/// 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
|
||||
/// control itself (a paragraph in the card reads as the first control's label).
|
||||
fn settings_card(controls: Vec<Element>) -> Element {
|
||||
card(vstack(controls).spacing(10.0)).into()
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
s.match_window = 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 names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
@@ -266,7 +208,8 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, 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 names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
@@ -280,26 +223,36 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, 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_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
})
|
||||
.tooltip(
|
||||
"Linux hosts only, and advisory \u{2014} the host falls back to auto-detect when the \
|
||||
choice is unavailable.",
|
||||
);
|
||||
let fullscreen_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Start streams fullscreen",
|
||||
s.fullscreen_on_stream,
|
||||
|s, on| s.fullscreen_on_stream = on,
|
||||
);
|
||||
)
|
||||
.tooltip("The stream window opens fullscreen; F11 or Alt+Enter switches back live.");
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
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| {
|
||||
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.
|
||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||
let gpus = crate::gpu::adapter_names();
|
||||
@@ -318,11 +271,18 @@ pub(crate) fn settings_page(
|
||||
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_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
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
|
||||
// round-trips exactly.
|
||||
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.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| {
|
||||
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 -----------------------------------------------------------------------------
|
||||
// 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.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| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
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_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
})
|
||||
.tooltip(
|
||||
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
s.inhibit_shortcuts,
|
||||
|s, on| s.inhibit_shortcuts = on,
|
||||
);
|
||||
)
|
||||
.tooltip("Off: Alt+Tab, Win & co. act on this machine while the stream input is captured.");
|
||||
|
||||
// --- Audio -----------------------------------------------------------------------------
|
||||
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| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
});
|
||||
})
|
||||
.tooltip("The host downmixes if its output has fewer channels.");
|
||||
let mic_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Stream microphone to the host",
|
||||
s.mic_enabled,
|
||||
|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_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
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 ss = set_screen.clone();
|
||||
@@ -431,6 +415,10 @@ pub(crate) fn settings_page(
|
||||
"Show game library (experimental)",
|
||||
s.library_enabled,
|
||||
|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
|
||||
// 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);
|
||||
|
||||
// The selected section's content, grouped exactly like the Apple client's categories
|
||||
// (SettingsCategory + SettingsView+Sections.swift). Each field's explanation sits under
|
||||
// it; the only form-level notes are the "applies from the next session" footers, matching
|
||||
// Apple's decision to keep exactly one of those per affected category.
|
||||
let (title, groups): (&str, Vec<Element>) = match section {
|
||||
"display" => {
|
||||
let mut out = group(
|
||||
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 {
|
||||
fields.push(described(
|
||||
c,
|
||||
"Which adapter decodes and presents the stream. Automatic uses the \
|
||||
GPU driving this window\u{2019}s display.",
|
||||
));
|
||||
}
|
||||
fields
|
||||
},
|
||||
None,
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described(
|
||||
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."),
|
||||
),
|
||||
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||
// card is just the controls.
|
||||
let (title, card): (&str, Element) = match section {
|
||||
"video" => (
|
||||
"Video",
|
||||
settings_card({
|
||||
let mut controls: Vec<Element> = vec![decoder_combo.into()];
|
||||
if let Some(c) = gpu_combo {
|
||||
controls.push(c.into());
|
||||
}
|
||||
controls.extend([
|
||||
codec_combo.into(),
|
||||
bitrate_box.into(),
|
||||
hdr_toggle.into(),
|
||||
hud_combo.into(),
|
||||
]);
|
||||
controls
|
||||
}),
|
||||
),
|
||||
"input" => (
|
||||
"Input",
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
touch_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
),
|
||||
"audio" => (
|
||||
"Audio",
|
||||
group(
|
||||
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."),
|
||||
),
|
||||
settings_card(vec![channels_combo.into(), mic_toggle.into()]),
|
||||
),
|
||||
"about" => (
|
||||
"About",
|
||||
group(
|
||||
None,
|
||||
vec![about_identity.into(), licenses_button.into()],
|
||||
None,
|
||||
),
|
||||
settings_card(vec![
|
||||
about_identity.into(),
|
||||
library_toggle.into(),
|
||||
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
|
||||
// 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.
|
||||
// Category order mirrors the Apple client's sidebar exactly.
|
||||
let items = vec![
|
||||
NavViewItem::new("General")
|
||||
.tag("general")
|
||||
.icon(Symbol::Setting),
|
||||
NavViewItem::new("Display")
|
||||
.tag("display")
|
||||
.icon(Symbol::FullScreen),
|
||||
NavViewItem::new("Video").tag("video").icon(Symbol::Video),
|
||||
NavViewItem::new("Input")
|
||||
.tag("input")
|
||||
.icon(Symbol::Keyboard),
|
||||
NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume),
|
||||
NavViewItem::new("Controllers")
|
||||
.tag("controllers")
|
||||
.icon(Symbol::Play),
|
||||
NavViewItem::new("About").tag("about").icon(Symbol::Help),
|
||||
];
|
||||
// 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
|
||||
// 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
|
||||
// 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)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
let content = page_wide(vec![card.with_key(section)])
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
NavigationView::new(items, content)
|
||||
.pane_title("Settings")
|
||||
.header(title)
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
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() {}
|
||||
@@ -76,27 +76,6 @@ fn main() {
|
||||
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.
|
||||
// Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI
|
||||
// call (build.rs stages the bootstrap DLL via windows-reactor-setup).
|
||||
|
||||
@@ -126,26 +126,21 @@ pub(crate) fn spawn_session(
|
||||
/// The same stdout contract as a connect (`--json-status`): `ready` when the library
|
||||
/// window presents, `error` on a failed start, EOF on quit.
|
||||
pub(crate) fn spawn_browse(
|
||||
target: Option<(&str, u16)>,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fullscreen: bool,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--browse");
|
||||
// A target opens straight into that host's library; bare `--browse` opens the console's
|
||||
// OWN host view (discovery, pairing, settings, Wake-on-LAN) — the couch equivalent of
|
||||
// the shell's hosts page.
|
||||
if let Some((addr, port)) = target {
|
||||
cmd.arg(format!("{addr}:{port}"));
|
||||
}
|
||||
cmd.arg("--json-status");
|
||||
cmd.arg("--browse")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--json-status");
|
||||
if fullscreen {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
||||
spawn_with(cmd, &label, slot, on_event)
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
}
|
||||
|
||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||
|
||||
@@ -244,13 +244,8 @@ pub struct ZeroCopyPolicy {
|
||||
/// 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`).
|
||||
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,
|
||||
/// 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.
|
||||
pub pyrowave_modifiers: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -255,11 +255,9 @@ fn spawn_pipewire(
|
||||
want_hdr
|
||||
};
|
||||
// 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
|
||||
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf
|
||||
// passthrough offer.
|
||||
let vaapi_dmabuf =
|
||||
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
|
||||
// backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s
|
||||
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
|
||||
let vaapi_dmabuf = zerocopy && !force_shm && policy.backend_is_vaapi;
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-pipewire".into())
|
||||
.spawn(move || {
|
||||
@@ -1310,25 +1308,21 @@ mod pipewire {
|
||||
/// (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) {
|
||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
||||
// are ALL producer-written, and without a bound against the actual region they drive
|
||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||
if meta.is_null() {
|
||||
// `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
|
||||
// `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
|
||||
// (or null), valid until requeue. The size argument matches the struct the result is cast to.
|
||||
let cur = unsafe {
|
||||
spa::sys::spa_buffer_find_meta_data(
|
||||
spa_buf,
|
||||
spa::sys::SPA_META_Cursor,
|
||||
std::mem::size_of::<spa::sys::spa_meta_cursor>(),
|
||||
) as *const spa::sys::spa_meta_cursor
|
||||
};
|
||||
if cur.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
||||
return;
|
||||
}
|
||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
||||
// SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
|
||||
// inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
@@ -1351,18 +1345,13 @@ mod pipewire {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
let bmp_off = bmp_off as usize;
|
||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
||||
Some(end) if end <= region_size => {}
|
||||
_ => return,
|
||||
}
|
||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
||||
// so the header is fully in bounds; 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.
|
||||
// SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
|
||||
// producer placed inside the same meta region it sized for this cursor (>= the size we
|
||||
// requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
|
||||
let bmp =
|
||||
unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
|
||||
// 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.
|
||||
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
||||
(
|
||||
(*bmp).format,
|
||||
@@ -1378,27 +1367,10 @@ mod pipewire {
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it
|
||||
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and
|
||||
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before
|
||||
// fabricating the slice — this is the check whose absence made the read go out of bounds.
|
||||
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 span = stride * (bh as usize - 1) + row;
|
||||
// SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
|
||||
// producer-sized meta region. `span` is the exact extent the strided copy below reads.
|
||||
let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
|
||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||
for y in 0..bh as usize {
|
||||
for x in 0..bw as usize {
|
||||
@@ -1973,18 +1945,16 @@ mod pipewire {
|
||||
// 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
|
||||
// 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
|
||||
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave
|
||||
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there
|
||||
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can
|
||||
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU
|
||||
// stack must not crash-loop).
|
||||
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
||||
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
|
||||
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
|
||||
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
|
||||
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
||||
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,
|
||||
// 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.
|
||||
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() {
|
||||
tracing::warn!(
|
||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||
@@ -2010,11 +1980,9 @@ mod pipewire {
|
||||
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
|
||||
// race).
|
||||
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'
|
||||
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
|
||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||
// dmabuf straight to the encoder.
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
||||
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
|
||||
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && backend_is_vaapi;
|
||||
// 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
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
@@ -2030,9 +1998,8 @@ mod pipewire {
|
||||
// advertisement with every modifier its device samples from, so compositors that
|
||||
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
|
||||
// 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
|
||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
||||
// own (the emptiness check gates it).
|
||||
// the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never
|
||||
// calls back into `encode` and needs no feature gate of its own (the emptiness check gates it).
|
||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !modifiers.contains(&m) {
|
||||
@@ -2052,11 +2019,11 @@ mod pipewire {
|
||||
);
|
||||
} else if zerocopy && !want_dmabuf {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
} else if vaapi_passthrough {
|
||||
tracing::info!(
|
||||
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||
);
|
||||
} else if want_dmabuf && !vaapi_passthrough {
|
||||
} else if want_dmabuf {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
@@ -2190,37 +2157,36 @@ mod pipewire {
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
||||
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the
|
||||
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is
|
||||
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was
|
||||
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded
|
||||
// `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 the
|
||||
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null
|
||||
// (null-checked), single-threaded so no concurrent access.
|
||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||
if newest.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut drained = 1u32;
|
||||
loop {
|
||||
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null.
|
||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||
if next.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately
|
||||
// overwrite it, so the requeued pointer is never touched again.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
newest = next;
|
||||
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.
|
||||
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||
// queued buffers, requeue the older ones, keep only the newest.
|
||||
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||
if newest.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut drained = 1u32;
|
||||
loop {
|
||||
// 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() };
|
||||
if next.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||
// 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) };
|
||||
newest = next;
|
||||
drained += 1;
|
||||
}
|
||||
// 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
|
||||
// load through a valid pointer — no mutation or aliasing.
|
||||
@@ -2299,18 +2265,19 @@ mod pipewire {
|
||||
"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;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||
// use-after-requeue. Loop thread inside `.process`.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}));
|
||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
||||
// 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) };
|
||||
if outcome.is_err() {
|
||||
// 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
|
||||
|
||||
@@ -1341,12 +1341,6 @@ impl IddPushCapturer {
|
||||
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.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_last = None;
|
||||
self.out_idx = 0;
|
||||
@@ -1867,7 +1861,6 @@ impl IddPushCapturer {
|
||||
cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
ring_gen: self.generation,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
@@ -1926,7 +1919,6 @@ impl IddPushCapturer {
|
||||
cbcr: dst_cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
ring_gen: self.generation,
|
||||
}),
|
||||
}),
|
||||
cursor: None,
|
||||
|
||||
@@ -64,10 +64,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the
|
||||
# method itself is feature-gated behind this.
|
||||
"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]
|
||||
|
||||
@@ -20,83 +20,6 @@ const MIC_FRAME: usize = 960;
|
||||
|
||||
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 {
|
||||
pcm_tx: SyncSender<Vec<f32>>,
|
||||
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
||||
@@ -195,26 +118,20 @@ fn pw_thread(
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
*pw::keys::MEDIA_ROLE => "Game",
|
||||
*pw::keys::NODE_NAME => "punktfunk-client",
|
||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||
*pw::keys::NODE_LATENCY => "240/48000",
|
||||
};
|
||||
// The Settings speaker pick (session main maps `Settings::speaker_device` here);
|
||||
// unset/empty = PipeWire's default routing.
|
||||
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 stream = pw::stream::StreamBox::new(
|
||||
&core,
|
||||
"punktfunk-client",
|
||||
properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
*pw::keys::MEDIA_ROLE => "Game",
|
||||
*pw::keys::NODE_NAME => "punktfunk-client",
|
||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||
*pw::keys::NODE_LATENCY => "240/48000",
|
||||
},
|
||||
)
|
||||
.context("pw Stream")?;
|
||||
|
||||
let ud = PlayerData {
|
||||
rx: pcm_rx,
|
||||
@@ -399,23 +316,18 @@ fn mic_thread(
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||
*pw::keys::MEDIA_ROLE => "Communication",
|
||||
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||
*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")?;
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
&core,
|
||||
"punktfunk-mic-capture",
|
||||
properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||
*pw::keys::MEDIA_ROLE => "Communication",
|
||||
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
||||
},
|
||||
)
|
||||
.context("pw mic Stream")?;
|
||||
|
||||
let ud = MicData {
|
||||
connector: connector.clone(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ mod video_software;
|
||||
mod video_vaapi;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
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).
|
||||
// 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
|
||||
|
||||
@@ -41,9 +41,6 @@ pub struct SessionParams {
|
||||
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
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::new`).
|
||||
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
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
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
|
||||
.mic_enabled
|
||||
.then(|| {
|
||||
@@ -447,16 +430,8 @@ fn pump(
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
match connector.next_frame(Duration::from_millis(20)) {
|
||||
Ok(frame) => {
|
||||
// The `received` point: reassembly COMPLETION, stamped by the core session as
|
||||
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead
|
||||
// would fold the pre-decode queue wait into `host+network` — a client-side
|
||||
// standing backlog masquerading as network latency (the 2026-07 two-pair
|
||||
// investigation). 0 = a core predating the stamp; fall back to the pull instant.
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns
|
||||
} else {
|
||||
now_ns()
|
||||
};
|
||||
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||
let received_ns = now_ns();
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
@@ -824,9 +799,6 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -124,12 +124,6 @@ pub struct KnownHost {
|
||||
/// pre-existing stores load; empty until first learned.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -207,7 +201,6 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
paired,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = known.save();
|
||||
}
|
||||
@@ -533,27 +526,6 @@ pub struct Settings {
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
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
|
||||
/// 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
|
||||
@@ -642,10 +614,6 @@ impl Default for Settings {
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
auto_wake: true,
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
|
||||
@@ -219,11 +219,6 @@ pub struct EncoderCaps {
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
|
||||
/// (and its GPU payload) alive until this frame's AU has been returned by
|
||||
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
|
||||
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
|
||||
/// loops already hold the frame across their poll drain; new callers must do the same.
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||
@@ -269,15 +264,6 @@ pub trait Encoder: Send {
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
|
||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||
/// switch may be deferred to the next safe point internally. `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.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||
@@ -307,16 +293,6 @@ pub trait Encoder: Send {
|
||||
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
|
||||
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
|
||||
fn set_wire_chunking(&mut self, _shard_payload: usize) {}
|
||||
/// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts
|
||||
/// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's
|
||||
/// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer
|
||||
/// rotates its output ring per delivered frame with no regard for encode completion, so a
|
||||
/// deeper pipeline lets it overwrite a texture mid-encode. That is visual corruption (torn or
|
||||
/// mixed frames), not UB, so it fails silently and intermittently.
|
||||
///
|
||||
/// Called once by the session glue after the capturer is known; a backend that copies its
|
||||
/// input, or is synchronous, ignores it. Default: no-op.
|
||||
fn set_input_ring_depth(&mut self, _depth: usize) {}
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
|
||||
@@ -826,7 +826,7 @@ impl NvencEncoder {
|
||||
(*f).linesize[i] as usize,
|
||||
)
|
||||
});
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts)
|
||||
} else if self.want_444 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!(
|
||||
@@ -839,11 +839,11 @@ impl NvencEncoder {
|
||||
let y_pitch = (*f).linesize[0] as usize;
|
||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f).linesize[1] as usize;
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch)
|
||||
} else {
|
||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f).linesize[0] as usize;
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch)
|
||||
};
|
||||
if let Err(e) = copy_res {
|
||||
ffi::av_frame_free(&mut f);
|
||||
|
||||
@@ -16,20 +16,12 @@
|
||||
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
|
||||
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
|
||||
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
|
||||
//! (NVENC rejects a null-`buf[0]` frame; the captured buffer is worker-owned CUDA-IPC memory
|
||||
//! recycled on drop, so registering it directly needs a contiguous worker-pool layout + a
|
||||
//! registration↔IPC-mapping lifetime tie — the true zero-copy follow-up, plan §7 LN2 v2).
|
||||
//! **Stream-ordered submit** (default, `PUNKTFUNK_NVENC_STREAM_ORDERED=0` reverts): the
|
||||
//! session's IO streams are bound to the encode thread's copy stream
|
||||
//! (`NvEncSetIOCudaStreams`), so in sync-retrieve depth-1 use the copy + cursor blend enqueue
|
||||
//! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream —
|
||||
//! the submit path's CPU stalls are gone even though the copy itself remains.
|
||||
//! (NVENC rejects a null-`buf[0]` frame and its CUDADEVICEPTR registration cache is bounded +
|
||||
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
||||
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
||||
//!
|
||||
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC`: `1` = always, `0` = never, unset =
|
||||
//! **adaptive** — engaged by the session loop's contention escalation via
|
||||
//! [`Encoder::set_pipelined`] when depth-1 can't hold cadence; at depth-1 it costs ~one loop
|
||||
//! tick of latency, which is why it is not simply on. gpu-contention plan §5.B, latency plan
|
||||
//! T2.2/§7 LN3): NVENC *async mode*
|
||||
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
|
||||
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
|
||||
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
||||
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
|
||||
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
||||
@@ -128,14 +120,6 @@ struct EncodeApi {
|
||||
encode_picture:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
|
||||
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
|
||||
/// `NvEncSetIOCudaStreams` — binds the session's input/output ordering to a CUDA stream so the
|
||||
/// input copy + cursor blend can enqueue without a CPU sync (stream-ordered submit). The two
|
||||
/// `NV_ENC_CUSTREAM_PTR` args are pointers TO `CUstream` values.
|
||||
set_io_cuda_streams: unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
nv::NV_ENC_CUSTREAM_PTR,
|
||||
nv::NV_ENC_CUSTREAM_PTR,
|
||||
) -> nv::NVENCSTATUS,
|
||||
}
|
||||
|
||||
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
|
||||
@@ -228,7 +212,6 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
|
||||
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
|
||||
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
|
||||
set_io_cuda_streams: list.nvEncSetIOCudaStreams.ok_or(MISSING)?,
|
||||
};
|
||||
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
|
||||
Ok(api)
|
||||
@@ -240,25 +223,15 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
/// bitstream/ring slot is never reused mid-encode.
|
||||
const POOL: usize = 8;
|
||||
|
||||
/// The operator's `PUNKTFUNK_NVENC_ASYNC` intent (the SAME knob as the Windows backend):
|
||||
/// `Some(true)` = force the two-thread retrieve from session open — note that at the Linux
|
||||
/// default pipeline depth of 1 this adds ~one loop tick of latency (the non-blocking poll's AU
|
||||
/// rides the next tick), so it only pays under GPU contention; `Some(false)` = never (also
|
||||
/// vetoes the session loop's contention escalation via [`Encoder::set_pipelined`]); `None`
|
||||
/// (unset) = adaptive — off until the session loop escalates on sustained cadence overrun.
|
||||
/// Unlike Windows this changes NO session parameter (Linux stays sync mode; only the blocking
|
||||
/// lock moves off the encode thread), so there is no async-rejecting config to fail the open.
|
||||
fn async_retrieve_env() -> Option<bool> {
|
||||
match std::env::var("PUNKTFUNK_NVENC_ASYNC") {
|
||||
Ok(v) if matches!(v.trim(), "1" | "true" | "yes" | "on") => Some(true),
|
||||
Ok(v) if matches!(v.trim(), "0" | "false" | "no" | "off") => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator forced the two-thread retrieve on from session open (see [`async_retrieve_env`]).
|
||||
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
|
||||
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
|
||||
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
|
||||
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
|
||||
/// config to fail the open.
|
||||
fn async_retrieve_requested() -> bool {
|
||||
async_retrieve_env() == Some(true)
|
||||
std::env::var("PUNKTFUNK_NVENC_ASYNC")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||
@@ -272,18 +245,6 @@ fn async_inflight_cap() -> usize {
|
||||
.clamp(2, POOL - 1)
|
||||
}
|
||||
|
||||
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
|
||||
/// blocking copies). With the session's IO streams bound to the encode thread's copy stream
|
||||
/// (`NvEncSetIOCudaStreams`), the input copy + cursor blend enqueue with NO CPU sync and
|
||||
/// `encode_picture` orders after them on the stream — deleting the 1–3 per-frame
|
||||
/// `cuStreamSynchronize` stalls from the submit path (latency plan §7 LN2). Sync-retrieve mode
|
||||
/// only, and only while nothing is in flight (see the gate in [`Encoder::submit`]).
|
||||
fn stream_ordered_requested() -> bool {
|
||||
std::env::var("PUNKTFUNK_NVENC_STREAM_ORDERED")
|
||||
.map(|v| v.trim() != "0")
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
||||
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
||||
/// the session it belongs to is destroyed).
|
||||
@@ -341,14 +302,7 @@ fn retrieve_loop(
|
||||
if let Err(e) = cuda::make_current() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
||||
}
|
||||
let mut jobs: u64 = 0;
|
||||
while let Ok(job) = work_rx.recv() {
|
||||
// In two-thread mode the host loop's `wait_us` wraps a non-blocking poll, so the real
|
||||
// encode wait (scheduling + ASIC) is measured by NO timer there — sample it here instead
|
||||
// (same PUNKTFUNK_PERF cadence as the submit split).
|
||||
let sample = pf_host_config::config().perf && jobs % 120 == 0;
|
||||
jobs += 1;
|
||||
let t0 = std::time::Instant::now();
|
||||
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
||||
// targeted; both it and the session stay valid until `teardown`, which joins this thread
|
||||
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
||||
@@ -387,16 +341,6 @@ fn retrieve_loop(
|
||||
)),
|
||||
}
|
||||
};
|
||||
if sample {
|
||||
if let Ok((data, _)) = &result {
|
||||
tracing::info!(
|
||||
lock_us = t0.elapsed().as_micros() as u64,
|
||||
au_kib = (data.len() / 1024) as u64,
|
||||
"NVENC retrieve lock (sampled): blocking lock_bitstream + AU copy on \
|
||||
pf-nvenc-out (the async-mode encode wait)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
||||
break; // encoder side gone (teardown drains us via join)
|
||||
}
|
||||
@@ -452,9 +396,6 @@ pub struct NvencCudaEncoder {
|
||||
/// submit). Empty until the session is initialized.
|
||||
ring: Vec<RingSlot>,
|
||||
next: usize,
|
||||
/// Frames submitted over the encoder's lifetime (never reset, unlike `next`) — drives the
|
||||
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
|
||||
frames: u64,
|
||||
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
||||
@@ -499,19 +440,6 @@ pub struct NvencCudaEncoder {
|
||||
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
||||
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
||||
async_rt: Option<AsyncRetrieve>,
|
||||
/// The session loop escalated into pipelined retrieve ([`Encoder::set_pipelined`], the
|
||||
/// contention analog of the capturer depth escalation). Sticky across session rebuilds
|
||||
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||
want_async: bool,
|
||||
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
||||
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
||||
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
||||
/// session is destroyed.
|
||||
io_stream: *mut *mut c_void,
|
||||
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
|
||||
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
|
||||
stream_ordered: bool,
|
||||
}
|
||||
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||
@@ -573,7 +501,6 @@ impl NvencCudaEncoder {
|
||||
hdr_meta: None,
|
||||
ring: Vec::new(),
|
||||
next: 0,
|
||||
frames: 0,
|
||||
bitstreams: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
frame_idx: 0,
|
||||
@@ -591,35 +518,9 @@ impl NvencCudaEncoder {
|
||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
last_rfi_range: None,
|
||||
async_rt: None,
|
||||
want_async: false,
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Engage the escalated pipelined retrieve at a safe point: nothing in flight, and — because
|
||||
/// a live session has its IO streams bound for stream-ordered submit, whose output-stream
|
||||
/// semantics would make every later stream op wait on the previous encode and so serialize a
|
||||
/// pipelined session — via a clean session rebuild WITHOUT the binding (the re-open's first
|
||||
/// frame is the standard session-opening IDR). No-op until [`want_async`](Self::want_async)
|
||||
/// is set and `pending` drains.
|
||||
fn maybe_engage_async(&mut self) {
|
||||
if !self.want_async || self.async_rt.is_some() || !self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.inited {
|
||||
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight; `teardown` handles
|
||||
// exactly this live-session state (and a torn-down encoder lazily re-inits on the
|
||||
// next submit, which spawns the retrieve thread and skips the IO-stream arming).
|
||||
unsafe { self.teardown() };
|
||||
tracing::info!(
|
||||
"NVENC pipelined-retrieve escalation: rebuilding the session without the \
|
||||
IO-stream binding (stream-ordered submit and two-thread retrieve are mutually \
|
||||
exclusive); next frame opens with an IDR"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||
unsafe fn teardown(&mut self) {
|
||||
if self.encoder.is_null() {
|
||||
@@ -656,14 +557,6 @@ impl NvencCudaEncoder {
|
||||
session's slot toward the concurrent-session cap"
|
||||
);
|
||||
}
|
||||
// The boxed CUstream the IO-stream binding pointed at — freed only now, AFTER the session
|
||||
// that referenced it is destroyed (created by `Box::into_raw` in `init_session`, freed
|
||||
// exactly once here; `io_stream` is nulled so a re-init can't double-free).
|
||||
if !self.io_stream.is_null() {
|
||||
drop(Box::from_raw(self.io_stream));
|
||||
self.io_stream = ptr::null_mut();
|
||||
}
|
||||
self.stream_ordered = false;
|
||||
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||
self.bitstreams.clear();
|
||||
self.pending.clear();
|
||||
@@ -726,10 +619,6 @@ impl NvencCudaEncoder {
|
||||
enc,
|
||||
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
|
||||
);
|
||||
// Sub-frame-output prerequisites (latency plan §7 LN1): logged for fleet visibility now,
|
||||
// consumed when slice-level readback lands. Not stored — LN1 re-probes when it configures.
|
||||
let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK);
|
||||
let dyn_slice = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_DYNAMIC_SLICE_MODE);
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
|
||||
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
|
||||
@@ -752,8 +641,6 @@ impl NvencCudaEncoder {
|
||||
rfi = self.rfi_supported,
|
||||
custom_vbv = self.custom_vbv,
|
||||
yuv444 = self.yuv444_supported,
|
||||
subframe_readback = subframe != 0,
|
||||
dynamic_slice = dyn_slice != 0,
|
||||
max = %format!("{wmax}x{hmax}"),
|
||||
"NVENC (Linux direct) capabilities probed"
|
||||
);
|
||||
@@ -920,11 +807,7 @@ impl NvencCudaEncoder {
|
||||
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
|
||||
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
|
||||
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
|
||||
// live pitched CUDA allocation on the shared context. `set_io_cuda_streams` takes `enc` plus
|
||||
// two pointers to the boxed live `CUstream` (`Box::into_raw`), which outlives the session —
|
||||
// freed exactly once: in `teardown` after `destroy_encoder` when armed, or via
|
||||
// `Box::from_raw` right here on the rejection path (where `io_stream` is never set). No
|
||||
// handle escapes the encode thread.
|
||||
// live pitched CUDA allocation on the shared context. No handle escapes the encode thread.
|
||||
unsafe {
|
||||
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
|
||||
// session open and every subsequent device→device input copy.
|
||||
@@ -1077,53 +960,13 @@ impl NvencCudaEncoder {
|
||||
self.inited = true;
|
||||
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
||||
// session parameter differs — teardown/rebuild always stops it before destroy.
|
||||
if async_retrieve_requested() || self.want_async {
|
||||
if async_retrieve_requested() {
|
||||
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
||||
tracing::info!(
|
||||
depth = async_inflight_cap(),
|
||||
escalated = self.want_async,
|
||||
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
||||
);
|
||||
}
|
||||
// Stream-ordered submit (latency plan §7 LN2): bind the session's IO streams to this
|
||||
// thread's copy stream so the input copy + cursor blend enqueue with no CPU sync and
|
||||
// `encode_picture` orders after them. Same stream both ways: input-stream semantics
|
||||
// start the encode only after our enqueued copies, output-stream semantics insert the
|
||||
// encode's completion INTO the stream — so later stream work (the next frame's copy
|
||||
// into a reused ring slot) also waits for it. Sync-retrieve mode only: in two-thread
|
||||
// mode the captured buffer may be recycled after `submit` returns while the stream
|
||||
// still holds its copy (the blocking copies are the lifetime guarantee there).
|
||||
if self.async_rt.is_none() && stream_ordered_requested() {
|
||||
let stream = cuda::copy_stream_handle();
|
||||
if !stream.is_null() {
|
||||
// The pointee must outlive the session (the driver takes CUstream POINTERS) —
|
||||
// box it; `teardown` frees it after `destroy_encoder`.
|
||||
let holder = Box::into_raw(Box::new(stream));
|
||||
match (api().set_io_cuda_streams)(
|
||||
enc,
|
||||
holder as nv::NV_ENC_CUSTREAM_PTR,
|
||||
holder as nv::NV_ENC_CUSTREAM_PTR,
|
||||
)
|
||||
.nv_ok()
|
||||
{
|
||||
Ok(()) => {
|
||||
self.io_stream = holder;
|
||||
self.stream_ordered = true;
|
||||
tracing::info!(
|
||||
"NVENC stream-ordered submit armed (IO streams bound — no CPU \
|
||||
sync in the submit path)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
drop(Box::from_raw(holder));
|
||||
tracing::debug!(
|
||||
status = ?e,
|
||||
"NvEncSetIOCudaStreams rejected — keeping blocking copies"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||
bit_depth = self.bit_depth,
|
||||
@@ -1137,10 +980,8 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
|
||||
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
|
||||
/// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior);
|
||||
/// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's
|
||||
/// IO-stream binding (stream-ordered submit — see the gate in [`Encoder::submit`]).
|
||||
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize, sync: bool) -> Result<()> {
|
||||
/// on the shared context, synchronized by the copy helpers).
|
||||
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize) -> Result<()> {
|
||||
let s = &self.ring[slot].surface;
|
||||
let base = s.ptr;
|
||||
let pitch = s.pitch;
|
||||
@@ -1155,16 +996,16 @@ impl NvencCudaEncoder {
|
||||
(base + pitch as u64 * hh, pitch),
|
||||
(base + 2 * pitch as u64 * hh, pitch),
|
||||
];
|
||||
cuda::copy_yuv444_to_device(buf, planes, sync)
|
||||
cuda::copy_yuv444_to_device(buf, planes)
|
||||
}
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||
if !buf.is_nv12() {
|
||||
bail!("NV12 session but the captured buffer has no chroma plane");
|
||||
}
|
||||
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
|
||||
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch, sync)
|
||||
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch)
|
||||
}
|
||||
_ => cuda::copy_device_to_device(buf, base, pitch, sync),
|
||||
_ => cuda::copy_device_to_device(buf, base, pitch),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,9 +1052,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||
),
|
||||
};
|
||||
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
||||
// (nothing in flight after the previous poll drained).
|
||||
self.maybe_engage_async();
|
||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||
let new_fmt = buffer_format(buf);
|
||||
@@ -1240,18 +1078,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
||||
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
||||
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
||||
// `init_session` publishes `self.encoder` before its remaining fallible steps (bitstream
|
||||
// buffers, input-surface alloc, `register_resource`), 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 and its registered input surfaces permanently. `teardown` keys off
|
||||
// `encoder.is_null()`, not `inited`, so it cleans up exactly this half-built state.
|
||||
if let Err(e) = self.init_session() {
|
||||
// SAFETY: 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_session()?;
|
||||
} else {
|
||||
// Steady state: the copy helpers need the shared context current on this thread.
|
||||
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
||||
@@ -1277,31 +1104,8 @@ impl Encoder for NvencCudaEncoder {
|
||||
let slot = self.next % POOL;
|
||||
self.next += 1;
|
||||
|
||||
// Sampled breakdown of the submit hot path under PUNKTFUNK_PERF (~1 line per 2 s at
|
||||
// 60 fps, the VAAPI submit-split convention): copy = the per-frame device→device input
|
||||
// copy (the zero-copy-registration target), blend = cursor overlay kernel (0 without a
|
||||
// cursor), map/pic = the NVENC map_input_resource / encode_picture launches. The host
|
||||
// loop's `submit_us` folds all four together; this is what splits them apart.
|
||||
let sample = pf_host_config::config().perf && self.frames % 120 == 0;
|
||||
self.frames += 1;
|
||||
|
||||
// Stream-ordered fast path (§7 LN2): enqueue the copy + blend with no CPU sync and let the
|
||||
// IO-stream binding order `encode_picture` after them — but ONLY while nothing is in
|
||||
// flight (true depth-1 usage). The gate is what makes this sound: with `pending` empty,
|
||||
// every prior encode was drained by a blocking `poll`, so (a) the ring slot being reused
|
||||
// was fully read, and (b) the caller still holds this frame's payload across the matching
|
||||
// `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode
|
||||
// (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty)
|
||||
// falls back to the blocking copy so an early-recycled source can never be read late.
|
||||
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
||||
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
||||
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
||||
let ordered = self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||
self.copy_into_slot(buf, slot, !ordered)?;
|
||||
let t_copy = t0.elapsed();
|
||||
self.copy_into_slot(buf, slot)?;
|
||||
|
||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
|
||||
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
|
||||
@@ -1344,12 +1148,12 @@ impl Encoder for NvencCudaEncoder {
|
||||
let (w, h) = (self.width, s.height);
|
||||
let r = match self.buffer_fmt {
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
|
||||
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
||||
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||
}
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
|
||||
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
|
||||
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
|
||||
}
|
||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered),
|
||||
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
if !self.cursor_blend_warned {
|
||||
@@ -1365,9 +1169,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
let t_blend = t0.elapsed() - t_copy;
|
||||
let t_map: std::time::Duration;
|
||||
let t_pic: std::time::Duration;
|
||||
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
|
||||
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
|
||||
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
|
||||
@@ -1375,11 +1176,8 @@ impl Encoder for NvencCudaEncoder {
|
||||
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
|
||||
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
|
||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
||||
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
||||
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
||||
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
||||
// blocking lock additionally proves the enqueued copy completed).
|
||||
// just filled by the (synchronized) device→device copy and is not overwritten until this slot
|
||||
// is reused POOL submits later, by which time this encode was polled (POOL ≥ in-flight depth).
|
||||
unsafe {
|
||||
let reg = self.ring[slot].reg;
|
||||
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
|
||||
@@ -1387,11 +1185,9 @@ impl Encoder for NvencCudaEncoder {
|
||||
registeredResource: reg,
|
||||
..Default::default()
|
||||
};
|
||||
let tm = std::time::Instant::now();
|
||||
(api().map_input_resource)(self.encoder, &mut mp)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("map_input_resource", e))?;
|
||||
t_map = tm.elapsed();
|
||||
|
||||
let pts = self.frame_idx as u64;
|
||||
self.frame_idx += 1;
|
||||
@@ -1461,11 +1257,9 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
let tp = std::time::Instant::now();
|
||||
(api().encode_picture)(self.encoder, &mut pic)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("encode_picture", e))?;
|
||||
t_pic = tp.elapsed();
|
||||
self.pending.push_back((
|
||||
self.bitstreams[slot],
|
||||
mp.mappedResource,
|
||||
@@ -1473,16 +1267,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
anchor,
|
||||
));
|
||||
}
|
||||
if sample {
|
||||
tracing::info!(
|
||||
copy_us = t_copy.as_micros() as u64,
|
||||
blend_us = t_blend.as_micros() as u64,
|
||||
map_us = t_map.as_micros() as u64,
|
||||
pic_us = t_pic.as_micros() as u64,
|
||||
"NVENC submit split (sampled): copy=input D2D copy blend=cursor map=map_input \
|
||||
pic=encode_picture launch"
|
||||
);
|
||||
}
|
||||
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
||||
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
||||
if let Some(rt) = &self.async_rt {
|
||||
@@ -1504,21 +1288,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||
if !on {
|
||||
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
||||
return self.want_async || self.async_rt.is_some();
|
||||
}
|
||||
if async_retrieve_env() == Some(false) {
|
||||
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
||||
}
|
||||
if !self.want_async && self.async_rt.is_none() {
|
||||
self.want_async = true;
|
||||
self.maybe_engage_async();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
@@ -2112,220 +1881,4 @@ mod tests {
|
||||
assert!(got, "recovered encoder must produce an AU");
|
||||
println!("nvenc_cuda open-failure recovery: cap hit → diagnosed → recovered in place");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the stream-ordered submit (latency plan §7 LN2) must ARM on a
|
||||
/// default-env session — `NvEncSetIOCudaStreams` accepted, boxed `CUstream` held. Guards
|
||||
/// against a silent fallback to blocking copies: a rejected binding still encodes correctly,
|
||||
/// just with the per-frame CPU syncs back, which no other test would notice.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_stream_ordered_arms() {
|
||||
const W: u32 = 640;
|
||||
const H: u32 = 360;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
||||
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||
println!("skipped: stream-ordered submit disabled by env");
|
||||
return;
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
8_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
let au = enc.poll().expect("poll").expect("AU");
|
||||
assert!(au.keyframe, "opening AU must be the session IDR");
|
||||
assert!(
|
||||
enc.stream_ordered,
|
||||
"IO-stream binding must arm on a default-env session (NvEncSetIOCudaStreams rejected?)"
|
||||
);
|
||||
assert!(
|
||||
!enc.io_stream.is_null(),
|
||||
"the boxed CUstream must be held while armed"
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
||||
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
||||
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
||||
/// post-escalation AU is the re-open's session-opening IDR; pipelined `poll` is
|
||||
/// non-blocking, so AUs may ride a later tick).
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_pipelined_escalation() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
if async_retrieve_env() == Some(false) {
|
||||
println!("skipped: PUNKTFUNK_NVENC_ASYNC=0 vetoes the escalation");
|
||||
return;
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
8_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
// Steady sync frames first (stream-ordered mode).
|
||||
for i in 0..3u32 {
|
||||
let frame = nv12_frame(W, H, i);
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
enc.poll().expect("poll").expect("AU");
|
||||
}
|
||||
assert!(enc.async_rt.is_none(), "session starts sync");
|
||||
assert!(enc.set_pipelined(true), "escalation must be accepted");
|
||||
let mut aus = 0usize;
|
||||
let mut first_key = false;
|
||||
for i in 3..13u32 {
|
||||
let frame = nv12_frame(W, H, i);
|
||||
enc.submit_indexed(&frame, i)
|
||||
.expect("submit post-escalation");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
if aus == 0 {
|
||||
first_key = au.keyframe;
|
||||
}
|
||||
aus += 1;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(3));
|
||||
}
|
||||
// Drain the pipelined tail (bounded).
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
|
||||
while aus < 10 && std::time::Instant::now() < deadline {
|
||||
if enc.poll().expect("poll").is_some() {
|
||||
aus += 1;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(
|
||||
enc.async_rt.is_some(),
|
||||
"retrieve thread must be live after escalation"
|
||||
);
|
||||
assert!(
|
||||
!enc.stream_ordered,
|
||||
"IO-stream binding must be gone in pipelined mode"
|
||||
);
|
||||
assert_eq!(aus, 10, "every post-escalation frame must deliver an AU");
|
||||
assert!(first_key, "first post-escalation AU is the re-open IDR");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`), MEASUREMENT probe for latency plan §7 LN1 — answers the
|
||||
/// go/no-go question for sub-frame slice output: with `PUNKTFUNK_NVENC_SLICES=4` +
|
||||
/// `PUNKTFUNK_NVENC_SUBFRAME=1`, do slices become READABLE incrementally while the frame is
|
||||
/// still encoding (and with what spacing), or does the driver only publish them at frame
|
||||
/// completion? Spins `lock_bitstream(doNotWait)` against the in-flight bitstream and prints a
|
||||
/// `(t_us, numSlices, bytes)` timeline. Asserts only the config half (4 slices materialize);
|
||||
/// the timeline is the experiment's output — read it with `--nocapture`. Run single-threaded
|
||||
/// (env vars are process-global): `-- --ignored --test-threads=1`.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_subframe_slice_probe() {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
let _guard = EnvGuard;
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
// Frame 0 opens the session (IDR) — drain it normally.
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit opening frame");
|
||||
enc.poll().expect("poll").expect("opening AU");
|
||||
|
||||
// Frame 1: spin doNotWait locks against the in-flight bitstream BEFORE the blocking poll.
|
||||
let frame = nv12_frame(W, H, 1);
|
||||
enc.submit_indexed(&frame, 1).expect("submit probed frame");
|
||||
let bs = enc.pending.back().expect("in-flight entry").0;
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut timeline: Vec<(u64, nv::NVENCSTATUS, u32, u32)> = Vec::new();
|
||||
let mut offsets = [0u32; 32];
|
||||
loop {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
outputBitstream: bs,
|
||||
sliceOffsets: offsets.as_mut_ptr(),
|
||||
..Default::default()
|
||||
};
|
||||
lock.set_doNotWait(1);
|
||||
// SAFETY: `bs` is the pool bitstream the just-submitted `encode_picture` targets and
|
||||
// the session is live for the whole test; `lock` (version set, doNotWait) and
|
||||
// `offsets` are live stack locals across the synchronous call; a successful lock is
|
||||
// unlocked before the next iteration reuses the struct. `reportSliceOffsets` was
|
||||
// armed at init so `sliceOffsets` may be written up to `numSlices` ≤ 32 entries
|
||||
// (sliceModeData = 4).
|
||||
let (status, n, bytes) = unsafe {
|
||||
let st = (api().lock_bitstream)(enc.encoder, &mut lock);
|
||||
let ok = st == nv::NVENCSTATUS::NV_ENC_SUCCESS;
|
||||
let (n, b) = if ok {
|
||||
(lock.numSlices, lock.bitstreamSizeInBytes)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
if ok {
|
||||
let _ = (api().unlock_bitstream)(enc.encoder, bs);
|
||||
}
|
||||
(st, n, b)
|
||||
};
|
||||
let t_us = t0.elapsed().as_micros() as u64;
|
||||
timeline.push((t_us, status, n, bytes));
|
||||
// A successful doNotWait lock on a COMPLETE frame reports the final slice count; on
|
||||
// LOCK_BUSY the frame is still encoding. Stop once complete (all 4 slices) or after
|
||||
// a generous 50 ms safety window.
|
||||
if (status == nv::NVENCSTATUS::NV_ENC_SUCCESS && n >= 4) || t_us > 50_000 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_micros(50));
|
||||
}
|
||||
println!("subframe probe timeline (t_us, status, numSlices, bytes):");
|
||||
for (t, st, n, b) in &timeline {
|
||||
println!(" {t:>7} us {st:?} slices={n} bytes={b}");
|
||||
}
|
||||
// Drain the probed frame through the normal path (lock again + unmap) — proves the probe
|
||||
// locks didn't corrupt the session.
|
||||
let au = enc.poll().expect("poll probed frame").expect("probed AU");
|
||||
assert!(!au.data.is_empty(), "probed AU must carry data");
|
||||
let last = timeline.last().expect("at least one sample");
|
||||
assert_eq!(
|
||||
last.2, 4,
|
||||
"4 slices must materialize (PUNKTFUNK_NVENC_SLICES=4 + subframe readback armed)"
|
||||
);
|
||||
// One more frame end-to-end for session health.
|
||||
let frame = nv12_frame(W, H, 2);
|
||||
enc.submit_indexed(&frame, 2).expect("submit follow-up");
|
||||
enc.poll().expect("poll").expect("follow-up AU");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,13 +865,6 @@ impl PyroWaveEncoder {
|
||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||
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 (w, h) = (self.width, self.height);
|
||||
dev.begin_command_buffer(
|
||||
@@ -1176,24 +1169,7 @@ impl Encoder for PyroWaveEncoder {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
|
||||
// struct owns and waits its own fence before touching results.
|
||||
let r = 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
|
||||
unsafe { self.encode_frame(frame) }
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
@@ -1219,12 +1195,6 @@ impl Encoder for PyroWaveEncoder {
|
||||
unsafe {
|
||||
self.device.device_wait_idle().ok();
|
||||
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 {
|
||||
device: self.pw_dev,
|
||||
width: self.width as i32,
|
||||
@@ -1239,10 +1209,6 @@ impl Encoder for PyroWaveEncoder {
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||
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;
|
||||
}
|
||||
self.pw_enc = enc;
|
||||
@@ -1288,11 +1254,7 @@ impl Drop for PyroWaveEncoder {
|
||||
// before the VkDevice they borrow (encoder before device, per pyrowave.h).
|
||||
unsafe {
|
||||
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);
|
||||
for (_, _, i, m, v) in self.import_cache.drain(..) {
|
||||
self.device.destroy_image_view(v, None);
|
||||
|
||||
@@ -77,32 +77,6 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
d: &pf_frame::DmabufFrame,
|
||||
cw: 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
|
||||
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer
|
||||
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one.
|
||||
#[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)> {
|
||||
use anyhow::Context;
|
||||
use std::os::fd::IntoRawFd;
|
||||
@@ -116,27 +90,26 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
.plane_layouts(&plane);
|
||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let mut ci = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.extent(vk::Extent3D {
|
||||
width: cw,
|
||||
height: ch,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
.usage(usage)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.push_next(&mut ext)
|
||||
.push_next(&mut drm);
|
||||
if let Some(pl) = profile_list {
|
||||
ci = ci.push_next(pl);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
let img = device.create_image(
|
||||
&vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.extent(vk::Extent3D {
|
||||
width: cw,
|
||||
height: ch,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
.usage(vk::ImageUsageFlags::SAMPLED)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.push_next(&mut ext)
|
||||
.push_next(&mut drm),
|
||||
None,
|
||||
)?;
|
||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||
let fd_props = {
|
||||
@@ -210,8 +183,7 @@ pub(crate) unsafe fn make_plain_image(
|
||||
None,
|
||||
)?;
|
||||
let req = device.get_image_memory_requirements(img);
|
||||
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple.
|
||||
let mem = match device.allocate_memory(
|
||||
let mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
@@ -220,24 +192,8 @@ pub(crate) unsafe fn make_plain_image(
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
)),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = make_view(device, img, fmt, 0)?;
|
||||
Ok((img, mem, view))
|
||||
}
|
||||
|
||||
@@ -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
@@ -101,15 +101,6 @@ pub(super) fn build_init_params(
|
||||
};
|
||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): 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
|
||||
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early).
|
||||
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
||||
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") {
|
||||
init.set_enableSubFrameWrite(1);
|
||||
init.set_reportSliceOffsets(1);
|
||||
}
|
||||
init
|
||||
}
|
||||
|
||||
@@ -127,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.frameIntervalP = 1;
|
||||
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;
|
||||
cfg.rcParams.averageBitRate = bps;
|
||||
cfg.rcParams.maxBitRate = bps;
|
||||
@@ -158,29 +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"),
|
||||
}
|
||||
|
||||
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's
|
||||
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) 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.
|
||||
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|n| (2..=32).contains(n))
|
||||
{
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -2077,15 +2077,9 @@ impl Encoder for AmfEncoder {
|
||||
}
|
||||
// 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.
|
||||
// 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 self.ltr_slots[slot].is_some() {
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
}
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
}
|
||||
// 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
|
||||
@@ -2369,16 +2363,6 @@ impl Encoder for AmfEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
||||
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
for marked in self.ltr_slots.iter_mut() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
*marked = None;
|
||||
}
|
||||
}
|
||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
||||
@@ -2407,9 +2391,6 @@ impl Encoder for AmfEncoder {
|
||||
true
|
||||
}
|
||||
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!(
|
||||
first,
|
||||
last,
|
||||
|
||||
@@ -409,12 +409,6 @@ pub struct NvencD3d11Encoder {
|
||||
events: Vec<usize>,
|
||||
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
|
||||
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.
|
||||
async_supported: bool,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||
@@ -511,7 +505,6 @@ impl NvencD3d11Encoder {
|
||||
bitstreams: Vec::new(),
|
||||
events: Vec::new(),
|
||||
async_rt: None,
|
||||
input_ring_depth: None,
|
||||
async_supported: false,
|
||||
pending: VecDeque::new(),
|
||||
frame_idx: 0,
|
||||
@@ -1142,19 +1135,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.chroma_444 = false;
|
||||
}
|
||||
let device = frame.device.clone();
|
||||
// `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
|
||||
// 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_session(&device)?;
|
||||
self.init_device = dev_raw;
|
||||
}
|
||||
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
|
||||
@@ -1163,21 +1144,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// index, which is non-zero on a mid-session encoder rebuild's first frame.
|
||||
let opening = self.next == 0;
|
||||
// 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
|
||||
// completion (the retrieve thread is already waiting on its event) before submitting more —
|
||||
// bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep
|
||||
// instead of 1.
|
||||
//
|
||||
// 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 {
|
||||
// keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
|
||||
// the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
|
||||
// event) before submitting more — bounding depth exactly like the sync path's per-tick
|
||||
// blocking poll, just `cap` deep instead of 1.
|
||||
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
|
||||
let done = {
|
||||
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
||||
rt.done_rx
|
||||
@@ -1353,17 +1324,6 @@ impl Encoder for NvencD3d11Encoder {
|
||||
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) {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,6 @@ const BS_SLACK: usize = 256 * 1024;
|
||||
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
|
||||
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
|
||||
// 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
|
||||
@@ -139,12 +136,8 @@ pub struct PyroWaveEncoder {
|
||||
// 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
|
||||
// NV12 import is unreliable on NVIDIA at arbitrary sizes).
|
||||
/// The capturer ring generation the cached plane imports below belong to. A recreate bumps it,
|
||||
/// and every cached import is destroyed — the COM addresses they are keyed on can be recycled
|
||||
/// 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)>,
|
||||
y_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
cbcr_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -275,7 +268,6 @@ impl PyroWaveEncoder {
|
||||
pw_dev,
|
||||
pw_enc,
|
||||
sync: std::ptr::null_mut(),
|
||||
ring_gen: None,
|
||||
y_images: Vec::new(),
|
||||
cbcr_images: Vec::new(),
|
||||
width,
|
||||
@@ -359,16 +351,10 @@ impl PyroWaveEncoder {
|
||||
///
|
||||
/// # Safety
|
||||
/// 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(
|
||||
cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>,
|
||||
cache: &mut Vec<(isize, pw::pyrowave_image)>,
|
||||
make: impl FnOnce() -> Result<pw::pyrowave_image>,
|
||||
key: PlaneKey,
|
||||
key: isize,
|
||||
) -> Result<pw::pyrowave_image> {
|
||||
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return Ok(*img);
|
||||
@@ -431,27 +417,6 @@ impl PyroWaveEncoder {
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
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 {
|
||||
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)",
|
||||
)?;
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
@@ -513,7 +459,7 @@ impl PyroWaveEncoder {
|
||||
};
|
||||
let pw_dev = self.pw_dev;
|
||||
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;
|
||||
Self::cached_plane(
|
||||
&mut self.y_images,
|
||||
@@ -522,7 +468,7 @@ impl PyroWaveEncoder {
|
||||
)?
|
||||
};
|
||||
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;
|
||||
Self::cached_plane(
|
||||
&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.
|
||||
unsafe {
|
||||
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 {
|
||||
device: self.pw_dev,
|
||||
width: self.width as i32,
|
||||
@@ -713,8 +655,6 @@ impl Encoder for PyroWaveEncoder {
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||
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;
|
||||
}
|
||||
self.pw_enc = enc;
|
||||
@@ -758,11 +698,7 @@ impl Drop for PyroWaveEncoder {
|
||||
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
|
||||
// before the device they borrow (per pyrowave.h).
|
||||
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(..)) {
|
||||
pw::pyrowave_image_destroy(img);
|
||||
}
|
||||
@@ -1024,9 +960,6 @@ mod tests {
|
||||
cbcr: cbcr_tex,
|
||||
fence_handle: Some(fence_handle.0 as isize),
|
||||
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,
|
||||
|
||||
@@ -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_AMF_LTR`); loss recovery then always falls back to IDR.
|
||||
fn ltr_disabled() -> bool {
|
||||
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
|
||||
// `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)
|
||||
std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
|
||||
fn intra_refresh_requested() -> bool {
|
||||
// Spelling parity with AMF (see `ltr_disabled` above).
|
||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
|
||||
/// 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.)
|
||||
/// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF.
|
||||
fn intra_refresh_period(fps: u32) -> u16 {
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or(fps / 2)
|
||||
.clamp(8, 240) as u16
|
||||
(fps / 2).clamp(8, 240) as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -714,16 +700,7 @@ pub struct QsvEncoder {
|
||||
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
|
||||
ltr_active: bool,
|
||||
/// 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],
|
||||
/// 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,
|
||||
ltr_mark_interval: i64,
|
||||
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
|
||||
@@ -798,7 +775,6 @@ impl QsvEncoder {
|
||||
ir_active: false,
|
||||
ltr_active: false,
|
||||
ltr_slots: [None; NUM_LTR_SLOTS],
|
||||
ltr_tainted: [false; NUM_LTR_SLOTS],
|
||||
next_ltr_slot: 0,
|
||||
ltr_mark_interval: ltr_mark_interval(fps),
|
||||
pending_force: None,
|
||||
@@ -923,7 +899,6 @@ impl QsvEncoder {
|
||||
self.ltr_active = ltr_active;
|
||||
self.ir_active = ir_active;
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.ltr_tainted = [false; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
self.hdr_applied = self.hdr_meta;
|
||||
@@ -1042,14 +1017,13 @@ impl Encoder for QsvEncoder {
|
||||
self.frame_idx += 1;
|
||||
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
|
||||
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;
|
||||
if self.ltr_active {
|
||||
if forced {
|
||||
// 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.
|
||||
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.pending_force = None;
|
||||
} else if self.ltr_test_force_at == Some(cur_idx) {
|
||||
@@ -1061,29 +1035,17 @@ impl Encoder for QsvEncoder {
|
||||
);
|
||||
}
|
||||
if let Some(slot) = self.pending_force.take() {
|
||||
// Resolve the anchor NOW: a taint sweep in `invalidate_ref_frames` may have
|
||||
// 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;
|
||||
}
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
}
|
||||
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||
let slot = self.next_ltr_slot;
|
||||
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;
|
||||
mark_slot = Some(slot);
|
||||
}
|
||||
}
|
||||
let ltr_slots = self.ltr_slots;
|
||||
let reject_ok = self.codec != Codec::Av1;
|
||||
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
||||
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
|
||||
// `poll`) instead of letting the queue grow under overload.
|
||||
@@ -1168,7 +1130,7 @@ impl Encoder for QsvEncoder {
|
||||
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
|
||||
// Per-frame control: forced IDR and/or the LTR reflist.
|
||||
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();
|
||||
if forced {
|
||||
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
||||
@@ -1186,55 +1148,24 @@ impl Encoder for QsvEncoder {
|
||||
c.reflist.ApplyLongTermIdx = 1;
|
||||
use_reflist = true;
|
||||
}
|
||||
if let Some((slot, ltr_frame)) = force_ltr {
|
||||
// Force THIS frame to predict only from the known-good LTR — the
|
||||
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
||||
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
||||
// FrameOrder).
|
||||
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
||||
c.reflist.PreferredRefList[0].PicStruct =
|
||||
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;
|
||||
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
|
||||
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
||||
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
||||
// FrameOrder).
|
||||
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
||||
c.reflist.PreferredRefList[0].PicStruct =
|
||||
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
||||
use_reflist = true;
|
||||
tracing::info!(
|
||||
slot,
|
||||
ltr_frame,
|
||||
frame = cur_idx,
|
||||
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
||||
no IDR)"
|
||||
);
|
||||
}
|
||||
use_reflist = true;
|
||||
tracing::info!(
|
||||
slot,
|
||||
ltr_frame,
|
||||
frame = cur_idx,
|
||||
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
||||
no IDR)"
|
||||
);
|
||||
}
|
||||
if use_reflist {
|
||||
c.attach_reflist();
|
||||
@@ -1334,29 +1265,8 @@ impl Encoder for QsvEncoder {
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||
// encoded inside the client's corrupt window — the client either never received it or
|
||||
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
||||
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
||||
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||
//
|
||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
||||
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
|
||||
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
|
||||
// could still predict from it. With two slots the "exactly one swept" case is the modal
|
||||
// one, and it was the broken one.
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
self.ltr_tainted[slot] = true;
|
||||
}
|
||||
}
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if self.ltr_tainted[slot] {
|
||||
continue; // still in the DPB, but encoded inside the corrupt window
|
||||
}
|
||||
if let Some(idx) = *marked {
|
||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||
best = Some((slot, idx));
|
||||
@@ -1377,9 +1287,6 @@ impl Encoder for QsvEncoder {
|
||||
true
|
||||
}
|
||||
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!(
|
||||
first,
|
||||
last,
|
||||
@@ -1459,21 +1366,15 @@ impl Encoder for QsvEncoder {
|
||||
let inner = self.inner.as_mut().expect("checked above");
|
||||
// Best-effort settle of in-flight operations (Close aborts them anyway).
|
||||
while sync_one(inner, 5).ok().flatten().is_some() {}
|
||||
// Close BEFORE dropping `pending`. Each `Pending` owns the `Box<BsBuf>` the runtime
|
||||
// is writing into asynchronously (and a `Box<FrameCtrl>` it reads), so clearing first
|
||||
// frees that heap while the operation is still live — a use-after-free by the VPL
|
||||
// runtime. The drain above is best-effort and bails on the first `Err`, which is
|
||||
// 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.
|
||||
inner.pending.clear();
|
||||
inner.ready.clear();
|
||||
inner.frames_submitted = 0;
|
||||
inner.first_au_logged = false;
|
||||
// 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`.
|
||||
unsafe {
|
||||
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
|
||||
};
|
||||
match self.init_encode(rebuilt) {
|
||||
@@ -1481,7 +1382,6 @@ impl Encoder for QsvEncoder {
|
||||
self.ltr_active = ltr;
|
||||
self.ir_active = ir;
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.ltr_tainted = [false; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
@@ -1945,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
|
||||
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
|
||||
#[test]
|
||||
|
||||
@@ -209,11 +209,6 @@ impl Encoder for TrackedEncoder {
|
||||
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
||||
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>> {
|
||||
self.inner.poll()
|
||||
}
|
||||
@@ -228,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
|
||||
/// 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
|
||||
@@ -419,14 +407,8 @@ fn open_video_backend(
|
||||
);
|
||||
}
|
||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
"auto" | "" => {
|
||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||
@@ -628,6 +610,8 @@ fn open_video_backend(
|
||||
(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
|
||||
// Software H.264 realistically caps far below the negotiated hardware rates.
|
||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
@@ -800,12 +784,6 @@ fn nvidia_present() -> bool {
|
||||
/// 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
|
||||
/// 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")]
|
||||
fn linux_auto_is_vaapi() -> bool {
|
||||
if let Some(g) = pf_gpu::manual_selection() {
|
||||
@@ -1019,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)
|
||||
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||||
// honor), since this probe can't know what the compositor will negotiate.
|
||||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
||||
// `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() {
|
||||
if linux_auto_is_vaapi() {
|
||||
vaapi::probe_can_encode_10bit(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_10bit(codec)
|
||||
@@ -1343,12 +1315,6 @@ mod vulkan_video;
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[path = "enc/linux/vk_av1_encode.rs"]
|
||||
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
|
||||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||||
#[cfg(all(
|
||||
|
||||
@@ -52,12 +52,6 @@ pub struct PyroFrameShare {
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::mem::size_of;
|
||||
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::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
|
||||
/// closes the gadget (the kernel tears down the device).
|
||||
pub struct SteamDeckGadget {
|
||||
@@ -242,7 +203,6 @@ pub struct SteamDeckGadget {
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
running: Arc<AtomicBool>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
wakers: Vec<Waker>,
|
||||
_fd: Arc<GadgetFd>,
|
||||
seq: u32,
|
||||
}
|
||||
@@ -283,18 +243,6 @@ impl SteamDeckGadget {
|
||||
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
||||
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.
|
||||
let control = {
|
||||
let fd = fd.clone();
|
||||
@@ -302,15 +250,10 @@ impl SteamDeckGadget {
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let feedback = feedback.clone();
|
||||
let tid = ctrl_waker.tid.clone();
|
||||
let done = ctrl_waker.done.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-ctrl".into())
|
||||
.spawn(move || {
|
||||
// SAFETY: `pthread_self` is always valid on the calling thread.
|
||||
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);
|
||||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||
})
|
||||
.context("spawn gadget control thread")?
|
||||
};
|
||||
@@ -321,16 +264,9 @@ impl SteamDeckGadget {
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let report = report.clone();
|
||||
let tid = stream_waker.tid.clone();
|
||||
let done = stream_waker.done.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-stream".into())
|
||||
.spawn(move || {
|
||||
// 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);
|
||||
})
|
||||
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
|
||||
.context("spawn gadget stream thread")?
|
||||
};
|
||||
|
||||
@@ -339,7 +275,6 @@ impl SteamDeckGadget {
|
||||
feedback,
|
||||
running,
|
||||
threads: vec![control, stream],
|
||||
wakers: vec![ctrl_waker, stream_waker],
|
||||
_fd: fd,
|
||||
seq: 0,
|
||||
})
|
||||
@@ -367,32 +302,6 @@ impl SteamDeckGadget {
|
||||
impl Drop for SteamDeckGadget {
|
||||
fn drop(&mut self) {
|
||||
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(..) {
|
||||
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())? };
|
||||
// `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).
|
||||
// HEAP-allocated, deliberately: `sw_create_cb` writes `result` + up to 127 u16 of instance id
|
||||
// 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 {
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
}));
|
||||
// SAFETY: info + the buffers outlive the call; `ctx` is a live heap allocation that outlives every
|
||||
// path below (reclaimed only where the callback provably ran). windows-rs returns the HSWDEVICE
|
||||
// (the C out-param) as the Result value.
|
||||
};
|
||||
// SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
|
||||
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
@@ -320,15 +313,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(ctx as *const c_void),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim;
|
||||
// `event` is valid and unreferenced.
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
drop(Box::from_raw(ctx));
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
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.
|
||||
// SAFETY: event is valid.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
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.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"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() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Ds4WinPad {
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_ds4_{index}");
|
||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
@@ -70,13 +70,13 @@ impl Ds4WinPad {
|
||||
usb_vid_pid: "VID_054C&PID_09CC",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual DualShock 4",
|
||||
})?; // Propagate, do NOT swallow — see below.
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// Swallowing a create failure here (the previous behaviour) latched the pad slot to
|
||||
// `Some(pad)` with no live devnode: `PadSlots::ensure` short-circuits on `is_some()` and
|
||||
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to
|
||||
// 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.
|
||||
}) {
|
||||
Ok((h, id)) => (Some(h), id),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// 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
|
||||
|
||||
@@ -82,16 +82,12 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
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 {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
@@ -99,14 +95,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(ctx as *const c_void),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim.
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
drop(Box::from_raw(ctx));
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
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.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
if wait != WAIT_OBJECT_0 {
|
||||
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"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() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
|
||||
@@ -66,7 +66,7 @@ impl DeckWinPad {
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_deck_{index}");
|
||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
@@ -77,8 +77,13 @@ impl DeckWinPad {
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
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);
|
||||
// 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.
|
||||
|
||||
@@ -11,11 +11,7 @@ repository.workspace = true
|
||||
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
|
||||
# Linux-only module — see lib.rs).
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own
|
||||
# `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 }
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
||||
pf-ffvk = { path = "../pf-ffvk" }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
|
||||
@@ -53,8 +53,6 @@ pub struct Capture {
|
||||
/// The touchscreen input model for this session, and — for trackpad/pointer — the
|
||||
/// gesture state machine finger events feed.
|
||||
touch_mode: TouchMode,
|
||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
||||
invert_scroll: bool,
|
||||
gestures: Gestures,
|
||||
}
|
||||
|
||||
@@ -70,11 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(
|
||||
connector: Arc<NativeClient>,
|
||||
touch_mode: TouchMode,
|
||||
invert_scroll: bool,
|
||||
) -> Capture {
|
||||
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
captured: false,
|
||||
@@ -85,7 +79,6 @@ impl Capture {
|
||||
scroll_acc: (0.0, 0.0),
|
||||
touch_slots: HashMap::new(),
|
||||
touch_mode,
|
||||
invert_scroll,
|
||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||
}
|
||||
}
|
||||
@@ -201,10 +194,9 @@ impl Capture {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
ay += f64::from(dy) * 120.0 * sign;
|
||||
ax += f64::from(dx) * 120.0 * sign;
|
||||
ay += f64::from(dy) * 120.0;
|
||||
ax += f64::from(dx) * 120.0;
|
||||
let vy = ay.trunc() as i32;
|
||||
if vy != 0 {
|
||||
ay -= f64::from(vy);
|
||||
|
||||
@@ -48,8 +48,6 @@ pub struct SessionOpts {
|
||||
/// `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.
|
||||
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.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -813,7 +811,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.ok();
|
||||
gamepad.attach(c.clone());
|
||||
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)
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
st.capture = Some(cap);
|
||||
|
||||
@@ -33,8 +33,6 @@ mod reconfig;
|
||||
mod resources;
|
||||
mod setup;
|
||||
|
||||
pub use setup::list_adapters;
|
||||
|
||||
/// One presenter iteration's video input.
|
||||
pub enum FrameInput<'a> {
|
||||
/// No new frame — re-composite the retained video image (expose/resize).
|
||||
|
||||
@@ -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;
|
||||
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
|
||||
fn pick_device(
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
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.
|
||||
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
|
||||
/// 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.
|
||||
static REAPER: Mutex<Vec<(Child, Instant)>> = 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);
|
||||
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
|
||||
|
||||
fn sweep_reaper() {
|
||||
let mut list = REAPER.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
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
|
||||
});
|
||||
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if let Some(mut child) = self.child.take() {
|
||||
if !matches!(child.try_wait(), Ok(Some(_))) {
|
||||
REAPER.lock().unwrap().push((child, Instant::now()));
|
||||
REAPER.lock().unwrap().push(child);
|
||||
}
|
||||
}
|
||||
sweep_reaper();
|
||||
|
||||
@@ -251,32 +251,6 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
||||
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.
|
||||
pub const CURSOR_MAX: u32 = 256;
|
||||
|
||||
@@ -380,7 +354,6 @@ impl CursorBlend {
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
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);
|
||||
@@ -397,7 +370,7 @@ impl CursorBlend {
|
||||
&mut a_ox 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)`.
|
||||
@@ -412,7 +385,6 @@ impl CursorBlend {
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
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);
|
||||
@@ -429,7 +401,7 @@ impl CursorBlend {
|
||||
&mut a_ox 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`).
|
||||
@@ -444,7 +416,6 @@ impl CursorBlend {
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
sync: bool,
|
||||
) -> 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_yp, mut a_uvp) = (pitch as i32, pitch as i32);
|
||||
@@ -470,20 +441,16 @@ impl CursorBlend {
|
||||
(a_cw as u32).div_ceil(2),
|
||||
(a_ch as u32).div_ceil(2),
|
||||
&mut args,
|
||||
sync,
|
||||
)
|
||||
}
|
||||
|
||||
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream; `sync` waits
|
||||
/// 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).
|
||||
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream, then synchronize.
|
||||
fn launch(
|
||||
&self,
|
||||
f: CUfunction,
|
||||
work_w: u32,
|
||||
work_h: u32,
|
||||
args: &mut [*mut c_void],
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
if work_w == 0 || work_h == 0 {
|
||||
return Ok(());
|
||||
@@ -491,11 +458,9 @@ impl CursorBlend {
|
||||
const B: u32 = 16;
|
||||
let stream = copy_stream();
|
||||
// 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
|
||||
// copies the parameter values during `cuLaunchKernel` itself, so they need not outlive
|
||||
// the call. Grid/block dims are non-zero. Launched on the copy stream (ordered after the
|
||||
// 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.
|
||||
// locals whose types match the kernel's C parameters (per the call site above); grid/block
|
||||
// dims are non-zero. Launched on the copy stream (ordered after the input-surface copy that
|
||||
// `copy_into_slot` already synchronized) then synchronized. Requires the context current.
|
||||
unsafe {
|
||||
ck(
|
||||
cuLaunchKernel(
|
||||
@@ -513,10 +478,7 @@ impl CursorBlend {
|
||||
),
|
||||
"cuLaunchKernel(cursor)",
|
||||
)?;
|
||||
if sync {
|
||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")?;
|
||||
}
|
||||
Ok(())
|
||||
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,13 +1003,7 @@ impl RegisteredTexture {
|
||||
// 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
|
||||
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
|
||||
// `count == 1` resource via `&mut self.resource` (a live field). It is issued on
|
||||
// `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()`.
|
||||
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
|
||||
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
||||
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
||||
// 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.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
"cuGraphicsMapResources",
|
||||
)?;
|
||||
let mut array: CUarray = std::ptr::null_mut();
|
||||
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");
|
||||
}
|
||||
let copy = CUDA_MEMCPY2D {
|
||||
@@ -1075,7 +1031,7 @@ impl RegisteredTexture {
|
||||
..Default::default()
|
||||
};
|
||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2");
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -1102,12 +1058,12 @@ impl RegisteredTexture {
|
||||
// so the map/unmap pair stays balanced and the array outlives the copy.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
"cuGraphicsMapResources",
|
||||
)?;
|
||||
let mut array: CUarray = std::ptr::null_mut();
|
||||
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");
|
||||
}
|
||||
let copy = CUDA_MEMCPY2D {
|
||||
@@ -1121,7 +1077,7 @@ impl RegisteredTexture {
|
||||
..Default::default()
|
||||
};
|
||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2(plane)");
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
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
|
||||
/// [`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`]).
|
||||
/// `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(
|
||||
src: &DeviceBuffer,
|
||||
dst_ptr: CUdeviceptr,
|
||||
dst_pitch: usize,
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
let copy = CUDA_MEMCPY2D {
|
||||
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||
@@ -1185,27 +1138,23 @@ pub fn copy_device_to_device(
|
||||
Height: src.height as usize,
|
||||
..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). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||
// the enqueue: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/`dstPitch` the
|
||||
// caller's live region, `width*4`×`height` within both; `sync: false` shifts the source-
|
||||
// lifetime obligation to the caller (documented above). Wrapper → live table.
|
||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(dev->dev)", sync) }
|
||||
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
|
||||
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
||||
}
|
||||
|
||||
/// 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]` +
|
||||
/// `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.
|
||||
/// `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(
|
||||
src: &DeviceBuffer,
|
||||
y_dst: CUdeviceptr,
|
||||
y_pitch: usize,
|
||||
uv_dst: CUdeviceptr,
|
||||
uv_pitch: usize,
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
let (src_uv_ptr, src_uv_pitch) = src
|
||||
.uv
|
||||
@@ -1234,16 +1183,15 @@ pub fn copy_nv12_to_device(
|
||||
Height: h / 2,
|
||||
..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
|
||||
// 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
|
||||
// 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
|
||||
// to the caller (documented above). Wrappers → live table.
|
||||
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
|
||||
unsafe {
|
||||
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
|
||||
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
|
||||
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||
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).
|
||||
/// 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.
|
||||
/// `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_yuv444_to_device(
|
||||
src: &DeviceBuffer,
|
||||
dsts: [(CUdeviceptr, usize); 3],
|
||||
sync: bool,
|
||||
) -> Result<()> {
|
||||
pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]) -> Result<()> {
|
||||
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
|
||||
let w = src.width as usize;
|
||||
let h = src.height as usize;
|
||||
@@ -1273,13 +1215,12 @@ pub fn copy_yuv444_to_device(
|
||||
Height: h,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
|
||||
// context current (documented). `©` is a live local outliving the enqueue;
|
||||
// SAFETY: unsafe `copy_blocking` device→device copy; the caller must have the shared
|
||||
// context current (documented). `©` is a live local outliving the synchronous call;
|
||||
// `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
|
||||
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
|
||||
// above). Wrapper → live table.
|
||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
|
||||
// both. Wrapper → live table.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -691,15 +691,6 @@ impl EglImporter {
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> 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()?;
|
||||
if self
|
||||
.linear_nv12_pool
|
||||
|
||||
@@ -86,11 +86,9 @@ impl VkBridge {
|
||||
// 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
|
||||
// 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
|
||||
// the synchronous `create_*`/`enumerate_*` call that reads them — the ladder loop rebuilds
|
||||
// `prio`/`gp_info`/`qci`/`exts` fresh per attempt, so every `enabled_extension_names(&exts)`
|
||||
// / `queue_priorities(&prio)` / `push_next(&mut gp_info)` borrow outlives its own
|
||||
// `create_device` call.
|
||||
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
|
||||
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
|
||||
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
|
||||
// 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
|
||||
// constructor shares nothing across threads.
|
||||
@@ -124,93 +122,23 @@ impl VkBridge {
|
||||
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
||||
as u32;
|
||||
|
||||
// Global-priority queue (latency plan §7 LN4, PyroWave's `ac0e7332` lever for the
|
||||
// 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 = [
|
||||
let exts = [
|
||||
ash::khr::external_memory_fd::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 mut gp_info = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default()
|
||||
.global_priority(try_priority.unwrap_or(vk::QueueGlobalPriorityKHR::MEDIUM));
|
||||
let mut qci0 = vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(qf)
|
||||
.queue_priorities(&prio);
|
||||
let mut exts: Vec<*const std::ffi::c_char> = base_exts.to_vec();
|
||||
if try_priority.is_some() {
|
||||
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(
|
||||
let prio = [1.0f32];
|
||||
let qci = [vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(qf)
|
||||
.queue_priorities(&prio)];
|
||||
let device = instance
|
||||
.create_device(
|
||||
phys,
|
||||
&vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&qci)
|
||||
.enabled_extension_names(&exts),
|
||||
None,
|
||||
) {
|
||||
Ok(d) => {
|
||||
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?)")
|
||||
}
|
||||
}
|
||||
};
|
||||
)
|
||||
.context("vkCreateDevice (external-memory extensions supported?)")?;
|
||||
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
||||
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`.
|
||||
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
|
||||
let dup = libc::dup(fd);
|
||||
if dup < 0 {
|
||||
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()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let buffer = self
|
||||
@@ -291,55 +212,41 @@ impl VkBridge {
|
||||
.push_next(&mut ext_info),
|
||||
None,
|
||||
)
|
||||
.context("create import buffer")?; // `dup` drops → closes on failure
|
||||
.context("create import buffer")?;
|
||||
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
||||
if let Err(e) = self.ext_fd.get_memory_fd_properties(
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup.as_raw_fd(),
|
||||
&mut fd_props,
|
||||
) {
|
||||
self.device.destroy_buffer(buffer, None);
|
||||
return Err(e).context("vkGetMemoryFdPropertiesKHR");
|
||||
}
|
||||
self.ext_fd
|
||||
.get_memory_fd_properties(
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup,
|
||||
&mut fd_props,
|
||||
)
|
||||
.context("vkGetMemoryFdPropertiesKHR")?;
|
||||
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,
|
||||
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()
|
||||
.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 memory = match self.device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size.max(size))
|
||||
.memory_type_index(mem_type)
|
||||
.push_next(&mut import)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
libc::close(raw); // failed import does not consume the fd
|
||||
self.device.destroy_buffer(buffer, None);
|
||||
return Err(anyhow!("import dmabuf memory: {e}"));
|
||||
}
|
||||
};
|
||||
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");
|
||||
}
|
||||
let memory = self
|
||||
.device
|
||||
.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size.max(size))
|
||||
.memory_type_index(mem_type)
|
||||
.push_next(&mut import)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| {
|
||||
libc::close(dup); // failed import does not consume the fd
|
||||
anyhow!("import dmabuf memory: {e}")
|
||||
})?;
|
||||
self.device
|
||||
.bind_buffer_memory(buffer, memory, 0)
|
||||
.context("bind import memory")?;
|
||||
self.src_cache.insert(
|
||||
fd,
|
||||
SrcBuf {
|
||||
@@ -356,11 +263,11 @@ impl VkBridge {
|
||||
if self.dst.as_ref().is_some_and(|d| d.size >= size) {
|
||||
return Ok(());
|
||||
}
|
||||
// Build the replacement FULLY before retiring the old one. Previously the old dst was
|
||||
// destroyed and `self.dst` nulled up front, so a failed rebuild both dropped the working
|
||||
// buffer AND leaked every object the partial rebuild created (`buffer`/`memory` are raw ash
|
||||
// handles with no Drop, and `VkBridge::drop` only frees the live `self.dst`). Now every
|
||||
// fallible step unwinds locally, and the swap happens only on full success.
|
||||
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
|
||||
}
|
||||
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let buffer = self
|
||||
@@ -378,63 +285,35 @@ impl VkBridge {
|
||||
.context("create export buffer")?;
|
||||
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
||||
let mem_type =
|
||||
match 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);
|
||||
}
|
||||
};
|
||||
self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
|
||||
let mut export = vk::ExportMemoryAllocateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||
let memory = match self.device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size)
|
||||
.memory_type_index(mem_type)
|
||||
.push_next(&mut export)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.device.destroy_buffer(buffer, None);
|
||||
return Err(e).context("allocate exportable memory");
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
|
||||
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()
|
||||
.memory(memory)
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
|
||||
) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
self.device.free_memory(memory, None);
|
||||
self.device.destroy_buffer(buffer, None);
|
||||
return Err(e).context("vkGetMemoryFdKHR");
|
||||
}
|
||||
};
|
||||
let memory = self
|
||||
.device
|
||||
.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size)
|
||||
.memory_type_index(mem_type)
|
||||
.push_next(&mut export)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
)
|
||||
.context("allocate exportable memory")?;
|
||||
self.device
|
||||
.bind_buffer_memory(buffer, memory, 0)
|
||||
.context("bind export memory")?;
|
||||
let opaque_fd = self
|
||||
.ext_fd
|
||||
.get_memory_fd(
|
||||
&vk::MemoryGetFdInfoKHR::default()
|
||||
.memory(memory)
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
|
||||
)
|
||||
.context("vkGetMemoryFdKHR")?;
|
||||
// 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 = match cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size) {
|
||||
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
|
||||
}
|
||||
let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
|
||||
.context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
|
||||
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
|
||||
self.dst = Some(DstBuf {
|
||||
buffer,
|
||||
@@ -665,19 +544,9 @@ impl VkBridge {
|
||||
self.device
|
||||
.queue_submit(self.queue, &[submit], self.fence)
|
||||
.context("queue submit")?;
|
||||
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
|
||||
// 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
|
||||
self.device
|
||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||
{
|
||||
let _ = self.device.device_wait_idle();
|
||||
let _ = self.device.reset_fences(&[self.fence]);
|
||||
return Err(e).context("fence wait");
|
||||
}
|
||||
.context("fence wait")?;
|
||||
self.device
|
||||
.reset_fences(&[self.fence])
|
||||
.context("reset fence")?;
|
||||
@@ -770,19 +639,9 @@ impl VkBridge {
|
||||
self.device
|
||||
.queue_submit(self.queue, &[submit], self.fence)
|
||||
.context("queue submit")?;
|
||||
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
|
||||
// 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
|
||||
self.device
|
||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||
{
|
||||
let _ = self.device.device_wait_idle();
|
||||
let _ = self.device.reset_fences(&[self.fence]);
|
||||
return Err(e).context("fence wait");
|
||||
}
|
||||
.context("fence wait")?;
|
||||
self.device
|
||||
.reset_fences(&[self.fence])
|
||||
.context("reset fence")?;
|
||||
|
||||
@@ -10,6 +10,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=src/abi.rs");
|
||||
println!("cargo:rerun-if-changed=src/config.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=cbindgen.toml");
|
||||
|
||||
|
||||
@@ -78,11 +78,6 @@ impl PunktfunkConfig {
|
||||
u8::try_from(self.fec_percent).map_err(|_| PunktfunkStatus::InvalidArg)?;
|
||||
let max_data_per_block =
|
||||
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 {
|
||||
role,
|
||||
phase,
|
||||
@@ -92,7 +87,7 @@ impl PunktfunkConfig {
|
||||
max_data_per_block,
|
||||
},
|
||||
shard_payload: self.shard_payload as usize,
|
||||
max_frame_bytes,
|
||||
max_frame_bytes: self.max_frame_bytes as usize,
|
||||
encrypt: self.encrypt != 0,
|
||||
key: self.key,
|
||||
salt: self.salt,
|
||||
@@ -130,12 +125,6 @@ pub struct PunktfunkFrame {
|
||||
pub frame_index: u32,
|
||||
pub pts_ns: u64,
|
||||
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.
|
||||
@@ -402,7 +391,6 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
frame_index: f.frame_index,
|
||||
pts_ns: f.pts_ns,
|
||||
flags: f.flags,
|
||||
received_ns: f.received_ns,
|
||||
};
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
@@ -468,31 +456,23 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let s = match unsafe { s.as_mut() } {
|
||||
Some(s) => s,
|
||||
None => return PunktfunkStatus::NullPointer as i32,
|
||||
};
|
||||
let cb = s.input_cb;
|
||||
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() } {
|
||||
Some(s) => s,
|
||||
None => return PunktfunkStatus::NullPointer as i32,
|
||||
};
|
||||
match s.inner.poll_input() {
|
||||
Ok(Some(ev)) => (ev, s.input_cb),
|
||||
Ok(None) => break,
|
||||
Err(e) => return e.status() as i32,
|
||||
match s.inner.poll_input() {
|
||||
Ok(Some(ev)) => {
|
||||
if let Some((cb, user)) = cb {
|
||||
cb(&ev as *const InputEvent, user);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
if let Some((cb, user)) = cb {
|
||||
cb(&ev as *const InputEvent, user);
|
||||
Ok(None) => break,
|
||||
Err(e) => return e.status() as i32,
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}));
|
||||
@@ -898,8 +878,8 @@ pub const PUNKTFUNK_GAMEPAD_AUTO: u32 = 0;
|
||||
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
|
||||
pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||
/// UHID DualSense (kernel `hid-playstation`): adaptive triggers, lightbar, touchpad, motion —
|
||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored on
|
||||
/// Linux (UHID) and Windows (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
||||
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||
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
|
||||
/// 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;
|
||||
/// 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
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored on Linux (UHID) and Windows
|
||||
/// (UMDF minidriver) hosts; otherwise the host falls back to X-Box 360.
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
/// 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.
|
||||
@@ -919,12 +899,10 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||
/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||
/// 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
|
||||
/// Windows (UMDF) hosts; otherwise the host falls back to X-Box 360.
|
||||
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||
/// 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
|
||||
/// `hid-nintendo`); otherwise the host falls back to X-Box 360.
|
||||
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||
/// 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
|
||||
@@ -1766,7 +1744,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
|
||||
frame_index: f.frame_index,
|
||||
pts_ns: f.pts_ns,
|
||||
flags: f.flags,
|
||||
received_ns: f.received_ns,
|
||||
};
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
@@ -1929,13 +1906,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
}
|
||||
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||
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
|
||||
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||
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 };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => {
|
||||
// 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()
|
||||
}
|
||||
Err(e) => 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
|
||||
/// 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
|
||||
@@ -3251,11 +3185,6 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
out: *mut u64,
|
||||
) -> PunktfunkStatus {
|
||||
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() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
@@ -3313,11 +3242,6 @@ pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
|
||||
out: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
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() } {
|
||||
Some(c) => c,
|
||||
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.
|
||||
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.
|
||||
/// 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
|
||||
@@ -327,7 +191,6 @@ mod frame_channel_tests {
|
||||
pts_ns: i as u64,
|
||||
flags: 0,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,12 +425,6 @@ impl NativeClient {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
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);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
@@ -462,14 +456,9 @@ impl NativeClient {
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
// The controller arms exactly when the pump does — all three terms, not two: Automatic
|
||||
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host
|
||||
// echoed the rate it actually configured. Dropping the last term made this
|
||||
// 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,
|
||||
// The controller arms exactly when the pump does (see `abr::BitrateController::new`
|
||||
// below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
|
||||
wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
|
||||
mode: mode_slot,
|
||||
host_fingerprint: negotiated.host_fingerprint,
|
||||
resolved_compositor: negotiated.compositor,
|
||||
@@ -714,23 +703,14 @@ impl NativeClient {
|
||||
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
||||
*self.probe.lock().unwrap() = ProbeState {
|
||||
active: true,
|
||||
duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
let sent = self
|
||||
.ctrl_tx
|
||||
self.ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps,
|
||||
duration_ms,
|
||||
}))
|
||||
.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
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Read the current speed-test measurement (partial until `done`, final once the host's
|
||||
@@ -765,9 +745,7 @@ impl NativeClient {
|
||||
0.0
|
||||
} as f32;
|
||||
// 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
|
||||
// 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 offered_wire = p.host_wire_packets + p.host_send_dropped;
|
||||
let host_drop_pct = if offered_wire > 0 {
|
||||
p.host_send_dropped as f64 / offered_wire as f64 * 100.0
|
||||
} else {
|
||||
|
||||
@@ -32,11 +32,6 @@ pub(crate) struct ProbeState {
|
||||
pub(crate) host_duration_ms: u32,
|
||||
/// The host's `ProbeResult` arrived → the measurement is final.
|
||||
pub(crate) done: bool,
|
||||
/// The requested burst length, so the pump can arm a watchdog for a host that never answers.
|
||||
/// Without one, an ignored `ProbeRequest` latches `active` forever and the pump's whole report
|
||||
/// tick — loss reports, the ABR window feed, the standing-latency ladder and pending clock
|
||||
/// re-syncs — stays suppressed for the rest of the session.
|
||||
pub(crate) duration_ms: u32,
|
||||
}
|
||||
|
||||
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
//! Control task: the handshake stream stays open for mid-stream renegotiation + speed tests.
|
||||
//! Outbound requests (mode switch, probe) and inbound replies (Reconfigured, ProbeResult) are
|
||||
//! multiplexed with `select!`; a single outbound channel (`ctrl_rx`) keeps one writer so the
|
||||
//! two `&mut ctrl_send` borrows don't collide across branches.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
pub(super) struct ControlTask {
|
||||
pub(super) ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||
pub(super) ctrl_send: quinn::SendStream,
|
||||
pub(super) ctrl_recv: io::MsgReader,
|
||||
/// `None` = no connect-time skew handshake (old host) — clock re-sync stays off.
|
||||
pub(super) clock_rtt_ns: Option<u64>,
|
||||
pub(super) mode_slot: Arc<Mutex<Mode>>,
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||
pub(super) clock_gen: Arc<AtomicU32>,
|
||||
/// Clipboard metadata events (ClipState/ClipOffer) feed the same event plane the
|
||||
/// clipboard task uses for fetch data.
|
||||
pub(super) clip_event_tx: std::sync::mpsc::SyncSender<ClipEventCore>,
|
||||
}
|
||||
|
||||
impl ControlTask {
|
||||
pub(super) async fn run(self) {
|
||||
let ControlTask {
|
||||
mut ctrl_rx,
|
||||
mut ctrl_send,
|
||||
mut ctrl_recv,
|
||||
clock_rtt_ns,
|
||||
mode_slot,
|
||||
probe,
|
||||
bitrate_ack,
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
clip_event_tx,
|
||||
} = self;
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
// its first no-op clock flush). Echoes interleave with the other control replies in
|
||||
// the read arm below; only when the host answered the connect-time handshake — an
|
||||
// old host would just eat the probes.
|
||||
let mut resync = ClockResync::new();
|
||||
let mut resync_tick = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
|
||||
CLOCK_RESYNC_INTERVAL,
|
||||
);
|
||||
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
req = ctrl_rx.recv() => {
|
||||
let Some(req) = req else { break }; // client dropped
|
||||
let bytes = match req {
|
||||
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||
CtrlRequest::Probe(p) => p.encode(),
|
||||
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
if clock_rtt_ns.is_none() {
|
||||
continue; // no connect-time handshake — host can't answer
|
||||
}
|
||||
resync.begin(wall_clock_ns()).encode()
|
||||
}
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
|
||||
let probe = resync.begin(wall_clock_ns());
|
||||
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = ctrl_recv.read_msg() => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
if ack.accepted {
|
||||
*mode_slot.lock().unwrap() = ack.mode;
|
||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||
} else {
|
||||
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||
}
|
||||
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||
let mut p = probe.lock().unwrap();
|
||||
// Freeze the delivered figures now (the burst is done), before resumed
|
||||
// video can inflate the packet counters.
|
||||
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
|
||||
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
|
||||
p.host_goodput_bytes = result.bytes_sent;
|
||||
p.host_au = result.packets_sent;
|
||||
p.host_wire_packets = result.wire_packets_sent;
|
||||
p.host_send_dropped = result.send_dropped;
|
||||
p.host_duration_ms = result.duration_ms;
|
||||
p.done = true;
|
||||
p.active = false; // burst over — the pump stops mirroring counters
|
||||
tracing::info!(
|
||||
host_goodput_bytes = result.bytes_sent,
|
||||
wire_packets_sent = result.wire_packets_sent,
|
||||
send_dropped = result.send_dropped,
|
||||
duration_ms = result.duration_ms,
|
||||
delivered_packets = p.delivered_packets,
|
||||
"speed-test probe result"
|
||||
);
|
||||
} else if let Ok(ack) = BitrateChanged::decode(&msg) {
|
||||
// Adaptive bitrate: the host's clamp is authoritative — park it for
|
||||
// the pump's controller (which also reads any ack as "this host
|
||||
// renegotiates", arming further steps).
|
||||
tracing::info!(
|
||||
kbps = ack.bitrate_kbps,
|
||||
"host re-targeted encoder bitrate"
|
||||
);
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||
ResyncStep::Probe(p) => {
|
||||
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||
// Never let a congested window bias the offset (frames read
|
||||
// late exactly then) — keep the old estimate and let the next
|
||||
// periodic batch try again.
|
||||
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
|
||||
// info, not debug: ≤1/min, and it is THE forensic
|
||||
// trail for a stale-offset (stepped/slewed wall clock)
|
||||
// latency plateau — the 2026-07 two-pair investigation
|
||||
// had to reconstruct this blind.
|
||||
tracing::info!(
|
||||
offset_ns,
|
||||
rtt_us = rtt_ns / 1000,
|
||||
"mid-stream clock re-sync applied"
|
||||
);
|
||||
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tracing::info!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
"clock re-sync batch discarded — RTT above the \
|
||||
connect-time baseline (congested window)"
|
||||
);
|
||||
}
|
||||
}
|
||||
ResyncStep::Idle => {}
|
||||
}
|
||||
} else if let Ok(state) = ClipState::decode(&msg) {
|
||||
// Host ack / policy / backend update for the toggle UI (try_send: a
|
||||
// lagging embedder drops the newest — a stale toggle heals on the next).
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::State {
|
||||
enabled: state.enabled,
|
||||
policy: state.policy,
|
||||
reason: state.reason,
|
||||
});
|
||||
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||
// The host copied something: surface the lazy format list; the embedder
|
||||
// fetches only if a local app pastes.
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
kinds: offer.kinds,
|
||||
});
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tag = ?msg.first(),
|
||||
len = msg.len(),
|
||||
"unknown control message — ignoring"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
//! The blocking data-plane pump: poll the session for access units, run the adaptive-FEC
|
||||
//! loss reports, the ABR controller + startup capacity probe, the jump-to-live detectors,
|
||||
//! and the standing-latency bleed, and hand frames to the embedder.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
/// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
|
||||
/// try_send drops the newest frame when the embedder lags (freshness over completeness).
|
||||
/// Speed-test filler ([`FLAG_PROBE`]) is folded into the probe accumulator instead of the
|
||||
/// decoder queue — it isn't video.
|
||||
pub(super) struct DataPump {
|
||||
pub(super) session: Session,
|
||||
pub(super) frames: Arc<FrameChannel>,
|
||||
pub(super) ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||
pub(super) shutdown: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
pub(super) hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||
pub(super) clock_gen: Arc<AtomicU32>,
|
||||
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// The embedder's REQUESTED rate (0 = Automatic — the only case the ABR arms).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The rate the host actually configured (echoed in Welcome).
|
||||
pub(super) resolved_bitrate_kbps: u32,
|
||||
pub(super) negotiated_codec: u8,
|
||||
}
|
||||
|
||||
impl DataPump {
|
||||
pub(super) fn run(self) {
|
||||
let DataPump {
|
||||
mut session,
|
||||
frames,
|
||||
ctrl_tx,
|
||||
shutdown: pump_shutdown,
|
||||
probe: pump_probe,
|
||||
hot_tids: pump_hot_tids,
|
||||
clock_offset: pump_clock_offset,
|
||||
clock_gen: pump_clock_gen,
|
||||
decode_lat: pump_decode_lat,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
} = self;
|
||||
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
|
||||
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
|
||||
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
|
||||
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
let mut last_report = Instant::now();
|
||||
let (
|
||||
mut last_recovered,
|
||||
mut last_late,
|
||||
mut last_received,
|
||||
mut last_dropped,
|
||||
mut last_bytes,
|
||||
) = (0u64, 0u64, 0u64, 0u64, 0u64);
|
||||
// PUNKTFUNK_PERF: per-window pump observability — the Session's receive stage split
|
||||
// (recv / decrypt / reassemble+FEC, see `Session::take_pump_perf`) and completed-AU
|
||||
// inter-arrival jitter. Smoothness has no metric otherwise: jump-to-live counters only
|
||||
// fire after the stream is already seconds behind.
|
||||
let pump_perf_on = std::env::var("PUNKTFUNK_PERF").is_ok_and(|v| v != "0");
|
||||
let mut arrivals_us: Vec<u32> = Vec::new();
|
||||
let mut last_arrival: Option<Instant> = None;
|
||||
// Adaptive bitrate (see `crate::abr`): armed only when the embedder asked for Automatic
|
||||
// (`bitrate_kbps == 0`) and the host echoed the rate it actually configured (an old host
|
||||
// echoes 0 → controller stays permanently off). Fed once per report window with the same
|
||||
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay, the
|
||||
// actual delivered throughput (climb gate + proven-throughput mark), and whether a
|
||||
// jump-to-live flush fired.
|
||||
// PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
|
||||
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
|
||||
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
|
||||
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
|
||||
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
|
||||
resolved_bitrate_kbps
|
||||
} else {
|
||||
0
|
||||
});
|
||||
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
|
||||
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
|
||||
// could NEVER climb out of. One speed-test burst shortly after the stream settles
|
||||
// measures what the link actually delivers; ×0.7 (headroom for FEC overhead + variance)
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
|
||||
&& !rate_pinned
|
||||
&& resolved_bitrate_kbps > 0
|
||||
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
|
||||
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
|
||||
let mut capacity_probe_deadline: Option<Instant> = None;
|
||||
// Edge detector + watchdog for a probe of EITHER origin (the startup capacity probe or an
|
||||
// embedder speed test via `NativeClient::request_probe`). The startup path had both built
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
let mut probe_watchdog: Option<Instant> = None;
|
||||
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||
let mut flush_in_window = false;
|
||||
// Jump-to-live state (see the guard in the loop below): when the clock-based over-bound
|
||||
// run began (`stale_since`, armed only when the skew handshake succeeded so the clocks
|
||||
// are comparable), when the clock-free non-draining-queue run began (`standing_since`),
|
||||
// and the last-jump instant for the shared cooldown. Wall-clock runs (T1.4), not frame
|
||||
// counts — the detectors' sensitivity must not scale with fps or repeat cadence.
|
||||
let mut stale_since: Option<Instant> = None;
|
||||
let mut standing_since: Option<Instant> = None;
|
||||
let mut last_flush: Option<Instant> = None;
|
||||
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
|
||||
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
|
||||
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
|
||||
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
|
||||
// the control task for an immediate re-sync (via the report tick): the flush finding no
|
||||
// local backlog IS the "the wall clock stepped under me" signal.
|
||||
let mut noop_clock_flushes: u32 = 0;
|
||||
let mut clock_detector_armed = true;
|
||||
let mut resync_wanted = false;
|
||||
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
||||
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
||||
// backlog, or a stale clock offset after a wall-clock step, either of which otherwise
|
||||
// reads as permanent extra "network" latency for the rest of the session.
|
||||
let mut standing_lat = StandingLatency::new();
|
||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
|
||||
// re-sync takes effect on the very next frame's latency math.
|
||||
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
|
||||
// An applied re-sync invalidates the staleness run measured under the OLD offset:
|
||||
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
|
||||
let gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
if gen != seen_clock_gen {
|
||||
seen_clock_gen = gen;
|
||||
stale_since = None;
|
||||
noop_clock_flushes = 0;
|
||||
// Every OWD reading shifted with the offset — the standing-latency floor and
|
||||
// any elevation measured under the old one are meaningless now. If a stale
|
||||
// offset WAS the elevation, this is also the moment it gets fixed.
|
||||
standing_lat.rebase();
|
||||
if !clock_detector_armed {
|
||||
clock_detector_armed = true;
|
||||
tracing::info!("clock re-sync applied — clock-based jump-to-live re-armed");
|
||||
}
|
||||
}
|
||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
||||
// measurement. Updated every iteration (not just on a produced frame) so they stay current
|
||||
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
|
||||
let st = session.stats();
|
||||
frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
|
||||
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
|
||||
let probe_active = {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active && !p.done {
|
||||
p.rx_packets_now = st.packets_received;
|
||||
p.rx_bytes_now = st.bytes_received;
|
||||
p.base_packets.get_or_insert(st.packets_received);
|
||||
p.base_bytes.get_or_insert(st.bytes_received);
|
||||
}
|
||||
p.active && !p.done
|
||||
};
|
||||
// A probe just ended (either kind): rebase EVERY window anchor past the burst. Its
|
||||
// FLAG_PROBE filler landed in `bytes_received`/`packets_received` (session.rs counts
|
||||
// every accepted datagram) but never reached the decoder, and the report tick was
|
||||
// suppressed for the whole burst, so `last_*` still points before it. Without this the
|
||||
// first post-burst window reads the burst rate as `actual_kbps` and poisons the ABR's
|
||||
// monotone proven-throughput high-water mark — which never decays — and divides the
|
||||
// window's loss by a packet count inflated with filler.
|
||||
if was_probing && !probe_active {
|
||||
last_recovered = st.fec_recovered_shards;
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_report = Instant::now();
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
// cannot latch `active` forever and suppress the report tick for the whole session.
|
||||
if !was_probing && probe_active {
|
||||
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
|
||||
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
|
||||
}
|
||||
if !probe_active {
|
||||
probe_watchdog = None;
|
||||
} else if let Some(deadline) = probe_watchdog {
|
||||
if Instant::now() >= deadline {
|
||||
probe_watchdog = None;
|
||||
pump_probe.lock().unwrap().active = false;
|
||||
tracing::warn!(
|
||||
"speed-test probe unanswered — clearing it so loss reports and ABR resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
was_probing = probe_active;
|
||||
// Fire the startup link-capacity probe once the stream has settled (see the constants
|
||||
// above), and fold its measurement into the ABR ceiling when the result lands.
|
||||
// Never steal the slot from an embedder speed test in flight: there is one `ProbeState`
|
||||
// and no correlation id, so a clobber both wrecks the user's "Test connection" figure
|
||||
// (its base counters get re-snapshotted mid-burst against the full-burst denominator)
|
||||
// and mis-scales our own ceiling. Retry once it finishes.
|
||||
if capacity_probe_at.is_some_and(|at| Instant::now() >= at) && probe_active {
|
||||
capacity_probe_at = Some(Instant::now() + CAPACITY_PROBE_DELAY);
|
||||
} else if capacity_probe_at.is_some_and(|at| Instant::now() >= at) {
|
||||
capacity_probe_at = None;
|
||||
*pump_probe.lock().unwrap() = ProbeState {
|
||||
active: true,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
..Default::default()
|
||||
};
|
||||
if ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps: CAPACITY_PROBE_KBPS,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
}))
|
||||
.is_ok()
|
||||
{
|
||||
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||
tracing::info!(
|
||||
target_kbps = CAPACITY_PROBE_KBPS,
|
||||
duration_ms = CAPACITY_PROBE_MS,
|
||||
"adaptive bitrate: startup link-capacity probe"
|
||||
);
|
||||
} else {
|
||||
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
|
||||
}
|
||||
}
|
||||
if let Some(deadline) = capacity_probe_deadline {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.done {
|
||||
capacity_probe_deadline = None;
|
||||
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||
/ p.host_duration_ms.max(1) as u64)
|
||||
as u32;
|
||||
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||
abr.set_ceiling(ceiling);
|
||||
tracing::info!(
|
||||
delivered_kbps,
|
||||
ceiling_kbps = ceiling,
|
||||
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
|
||||
// the decoder — rebase the ABR window's byte counter past it, or the next
|
||||
// window's "actual throughput" reads as the burst rate and poisons the
|
||||
// controller's proven-throughput high-water mark with the LINK rate.
|
||||
last_bytes = st.bytes_received;
|
||||
} else if Instant::now() >= deadline {
|
||||
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||
p.active = false;
|
||||
capacity_probe_deadline = None;
|
||||
tracing::info!(
|
||||
"adaptive bitrate: capacity probe timed out (old host?) — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
}
|
||||
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
||||
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||
if resync_wanted {
|
||||
resync_wanted = false;
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||
}
|
||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||
let loss_ppm = window_loss_ppm(
|
||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
st.fec_late_shards.wrapping_sub(last_late),
|
||||
st.packets_received.wrapping_sub(last_received),
|
||||
window_dropped,
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
// from a stepped wall clock produces exactly this signature and the applied
|
||||
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
|
||||
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
|
||||
// loud disarm (the path latency itself changed; nothing local fixes that).
|
||||
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
|
||||
StandingLatAction::None => {}
|
||||
StandingLatAction::Resync { above_ms } => {
|
||||
tracing::info!(
|
||||
above_ms,
|
||||
"standing latency above the session floor with zero loss — \
|
||||
requesting a clock re-sync first (a stale offset reads exactly \
|
||||
like this)"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||
}
|
||||
StandingLatAction::Bleed { above_ms } => {
|
||||
// Shares the jump-to-live cooldown: an unexecuted bleed simply re-arms
|
||||
// over the next windows (the detector's run rebuilds).
|
||||
if last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) {
|
||||
last_flush = Some(Instant::now());
|
||||
// Deliberately NOT `flush_in_window = true`: that flag is the ABR's
|
||||
// SEVERE verdict (an immediate ×0.7 back-off), and the bleed fires
|
||||
// only after ~6 provably loss-free windows with a sub-25ms elevation
|
||||
// the controller itself scores as fine. The bleed's effect reaches
|
||||
// the ABR through the window's own honest signals (OWD/loss/decode);
|
||||
// the flag stays exclusive to the jump-to-live path below.
|
||||
let flushed = session.flush_backlog().unwrap_or(0);
|
||||
let dropped = frames.clear();
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||
standing_lat.bled();
|
||||
tracing::warn!(
|
||||
above_ms,
|
||||
flushed_datagrams = flushed,
|
||||
dropped_frames = dropped,
|
||||
"standing latency survived a clock re-sync — bled the local \
|
||||
backlog (flush + keyframe)"
|
||||
);
|
||||
}
|
||||
}
|
||||
StandingLatAction::Disarm { above_ms } => {
|
||||
tracing::warn!(
|
||||
above_ms,
|
||||
"standing latency persists after a re-sync and every bleed — not \
|
||||
local, not clock; the path latency changed. Leaving it be \
|
||||
(reconnect re-baselines)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
||||
// feed the controller this window's congestion signals; a decision becomes a
|
||||
// SetBitrate on the control stream.
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
let owd_mean_us =
|
||||
(owd_frames > 0).then(|| (owd_sum_ns / owd_frames as i128 / 1000) as i64);
|
||||
(owd_sum_ns, owd_frames) = (0, 0);
|
||||
// Drain the embedder's decode-latency window (always, so it stays bounded even when
|
||||
// the controller is disabled) → the mean feeds the decode signal; `None` when the
|
||||
// embedder reported nothing this window (old embedder / no decoded frames).
|
||||
let decode_mean_us = {
|
||||
let mut acc = pump_decode_lat.lock().unwrap();
|
||||
let (sum, count) = (acc.sum_us, acc.count);
|
||||
*acc = DecodeLatAcc::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
// semantics (both compare against targets with their own headroom).
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
if let Some(kbps) = abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
tracing::info!(
|
||||
kbps,
|
||||
loss_ppm,
|
||||
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||
actual_kbps,
|
||||
flushed = flush_in_window,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||
}
|
||||
flush_in_window = false;
|
||||
last_report = Instant::now();
|
||||
last_recovered = st.fec_recovered_shards;
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
if pump_perf_on {
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
tracing::info!(
|
||||
recv_ms = p.recv_ns / 1_000_000,
|
||||
decrypt_ms = p.decrypt_ns / 1_000_000,
|
||||
reasm_ms = p.reasm_ns / 1_000_000,
|
||||
packets = p.packets,
|
||||
batches = p.batches,
|
||||
pkts_per_batch = p.packets.checked_div(p.batches).unwrap_or(0),
|
||||
decrypt_ns_pkt = per_pkt_ns(p.decrypt_ns),
|
||||
reasm_ns_pkt = per_pkt_ns(p.reasm_ns),
|
||||
"pump stage split (window)"
|
||||
);
|
||||
}
|
||||
// Inter-arrival jitter over the window's completed AUs. `late` counts gaps
|
||||
// over 2× the window median — the "a frame arrived visibly off-beat" tally.
|
||||
if arrivals_us.len() >= 8 {
|
||||
arrivals_us.sort_unstable();
|
||||
let pct = |q: usize| arrivals_us[(arrivals_us.len() - 1) * q / 100];
|
||||
let (p50, p95) = (pct(50), pct(95));
|
||||
let late = arrivals_us.iter().filter(|&&d| d > p50 * 2).count();
|
||||
tracing::info!(
|
||||
frames = arrivals_us.len() + 1,
|
||||
arrival_p50_us = p50,
|
||||
arrival_p95_us = p95,
|
||||
arrival_max_us = arrivals_us.last().copied().unwrap_or(0),
|
||||
late,
|
||||
"frame inter-arrival jitter (window)"
|
||||
);
|
||||
}
|
||||
arrivals_us.clear();
|
||||
}
|
||||
}
|
||||
match session.poll_frame() {
|
||||
Ok(frame) => {
|
||||
if frame.flags & FLAG_PROBE as u32 != 0 {
|
||||
continue; // speed-test filler, not video — measured via the counters above
|
||||
}
|
||||
if pump_perf_on {
|
||||
let now = Instant::now();
|
||||
if let Some(prev) = last_arrival.replace(now) {
|
||||
// 4096 ≈ 17 s at 240 fps — a stuck window can't grow it unbounded.
|
||||
if arrivals_us.len() < 4096 {
|
||||
arrivals_us
|
||||
.push((now - prev).as_micros().min(u32::MAX as u128) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
|
||||
// the pump consumes strictly in order at the arrival rate, so once behind, the
|
||||
// stream stays behind for good (observed live: stuck 6–7 s). Pre-decode AUs are
|
||||
// reference-chained (infinite GOP), so we can NOT drop a frame mid-stream to catch
|
||||
// up; the only safe recovery is to discard the whole backlog and re-anchor decode
|
||||
// on a fresh keyframe. Two independent "we're behind" signals arm it, both gated by
|
||||
// FLUSH_COOLDOWN, both suspended during a speed test (the probe MEASURES a saturated
|
||||
// queue; flushing would corrupt its counters):
|
||||
// * clock-based — completed frames sit > FLUSH_LATENCY behind the skew-corrected
|
||||
// capture clock continuously for FLUSH_AFTER. Needs the skew handshake, and
|
||||
// also catches kernel/reassembler backlog the hand-off queue hasn't reached yet.
|
||||
// * clock-free — the pre-decode hand-off queue stopped draining: its depth stayed
|
||||
// ≥ QUEUE_HIGH (never falling to QUEUE_LOW, still high at the trip) for
|
||||
// STANDING_TIME. Works with no handshake / a same-clock session (where the
|
||||
// clock path is disarmed), and is the direct signal that the embedder can't
|
||||
// keep up. A transient Wi-Fi clump drains within ~100 ms and never trips it.
|
||||
if probe_active {
|
||||
// Keep both detectors disarmed across a speed test so its (deliberately)
|
||||
// saturated queue doesn't leave a primed run that fires the moment it ends.
|
||||
stale_since = None;
|
||||
standing_since = None;
|
||||
} else {
|
||||
let lat_ns = if clock_offset_ns != 0 {
|
||||
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Feed the adaptive-bitrate controller's OWD window (mean capture→received
|
||||
// delay): rising delay under zero loss is queue growth — the pre-loss
|
||||
// congestion signal. Only meaningful with a clock handshake.
|
||||
if clock_offset_ns != 0 && lat_ns > 0 {
|
||||
owd_sum_ns += lat_ns;
|
||||
owd_frames += 1;
|
||||
// The standing-latency detector rides the same signal, but off the
|
||||
// window MINIMUM (robust against jitter/burst spikes — a standing
|
||||
// state elevates the floor itself). Same 10 s plausibility clamp as
|
||||
// the hn stats use.
|
||||
if lat_ns < 10_000_000_000 {
|
||||
standing_lat.note_frame(lat_ns);
|
||||
}
|
||||
}
|
||||
if clock_detector_armed
|
||||
&& clock_offset_ns != 0
|
||||
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
|
||||
{
|
||||
stale_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
stale_since = None;
|
||||
}
|
||||
let depth = frames.depth();
|
||||
if depth >= QUEUE_HIGH {
|
||||
standing_since.get_or_insert_with(Instant::now);
|
||||
} else if depth <= QUEUE_LOW {
|
||||
standing_since = None;
|
||||
}
|
||||
// The queue trip additionally requires the depth to still be high NOW, so
|
||||
// a run that started ≥ high but is hovering in the hysteresis band (a
|
||||
// clump mid-drain) never fires on elapsed time alone.
|
||||
let clock_behind = stale_since.is_some_and(|t| t.elapsed() >= FLUSH_AFTER);
|
||||
let queue_behind = depth >= QUEUE_HIGH
|
||||
&& standing_since.is_some_and(|t| t.elapsed() >= STANDING_TIME);
|
||||
if (clock_behind || queue_behind)
|
||||
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
|
||||
{
|
||||
stale_since = None;
|
||||
standing_since = None;
|
||||
last_flush = Some(Instant::now());
|
||||
flush_in_window = true; // strongest "link can't hold the rate" signal
|
||||
let flushed = session.flush_backlog().unwrap_or(0);
|
||||
let dropped = frames.clear();
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||
tracing::warn!(
|
||||
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
|
||||
queue_depth = depth,
|
||||
flushed_datagrams = flushed,
|
||||
dropped_frames = dropped,
|
||||
"receive backlog stopped draining — jumped to live (flush + keyframe)"
|
||||
);
|
||||
// Clock-detector health check: a clock-only trigger whose flush found
|
||||
// no local backlog is a false "behind" reading (a wall-clock step, or
|
||||
// an upstream queue a local flush can't drain) — repeated, it would
|
||||
// cost a recovery IDR every cooldown forever. Disarm after two in a
|
||||
// row; the clock-free queue detector keeps covering real backlogs.
|
||||
if clock_behind
|
||||
&& !queue_behind
|
||||
&& flushed < NOOP_FLUSH_DATAGRAMS
|
||||
&& dropped == 0
|
||||
{
|
||||
noop_clock_flushes += 1;
|
||||
if noop_clock_flushes == 1 {
|
||||
// First no-op flush = a wall-clock step is the prime
|
||||
// suspect: ask for an immediate re-sync (sent on the next
|
||||
// report tick). Applied, it resets these counters and
|
||||
// re-arms the detector before the disarm below triggers.
|
||||
resync_wanted = true;
|
||||
}
|
||||
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
|
||||
clock_detector_armed = false;
|
||||
tracing::warn!(
|
||||
"clock-based jump-to-live disarmed — its flushes found no \
|
||||
local backlog (clock step or upstream queueing suspected); \
|
||||
the queue-depth detector stays armed"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
noop_clock_flushes = 0;
|
||||
}
|
||||
continue; // this frame is part of the stale past — don't render it
|
||||
}
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {
|
||||
std::thread::sleep(Duration::from_micros(300));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
// The pump exited (shutdown / fatal session error) — wake any consumer blocked in
|
||||
// `next_frame` with a Closed signal instead of a spurious timeout (the old mpsc did this
|
||||
// implicitly when the sender dropped).
|
||||
frames.close();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//! Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
||||
//! newest packet rather than backing up the QUIC receive path).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
audio_tx: std::sync::mpsc::SyncSender<AudioPacket>,
|
||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||
rumble_feed: super::super::rumble::RumbleFeed,
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
) {
|
||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
|
||||
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
|
||||
while let Ok(d) = conn.read_datagram().await {
|
||||
match d.first() {
|
||||
Some(&crate::quic::AUDIO_MAGIC) => {
|
||||
if let Some((seq, pts_ns, opus)) = crate::quic::decode_audio_datagram(&d) {
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq,
|
||||
pts_ns,
|
||||
data: opus.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
} else {
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if fresh {
|
||||
let ttl = u.envelope.map(|e| e.ttl_ms);
|
||||
// Both consumers are fed; an embedder drains exactly one of them
|
||||
// (the legacy queue, or the policy engine's command API).
|
||||
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
||||
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HIDOUT_MAGIC) => {
|
||||
if let Some(h) = HidOutput::decode(&d) {
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
||||
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
||||
let _ = host_timing_tx.try_send(t);
|
||||
}
|
||||
}
|
||||
_ => {} // unknown tag — a newer host; ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! Connect + handshake: dial the host (cert-pinned), exchange Hello/Welcome/Start on the
|
||||
//! control stream, run the wall-clock skew handshake, hole-punch the data port, and stand
|
||||
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
|
||||
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
|
||||
pub(super) struct HandshakeOut {
|
||||
pub(super) conn: quinn::Connection,
|
||||
pub(super) session: Session,
|
||||
pub(super) ctrl_send: quinn::SendStream,
|
||||
pub(super) ctrl_recv: io::MsgReader,
|
||||
pub(super) negotiated: Negotiated,
|
||||
pub(super) host_caps: u8,
|
||||
}
|
||||
|
||||
pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<HandshakeOut> {
|
||||
let (host, port, pin) = (&args.host, args.port, args.pin);
|
||||
let (mode, compositor, gamepad) = (args.mode, args.compositor, args.gamepad);
|
||||
let (bitrate_kbps, video_caps, audio_channels) =
|
||||
(args.bitrate_kbps, args.video_caps, args.audio_channels);
|
||||
let (video_codecs, preferred_codec, display_hdr) =
|
||||
(args.video_codecs, args.preferred_codec, args.display_hdr);
|
||||
let (launch, identity, shutdown) = (&args.launch, &args.identity, &args.shutdown);
|
||||
let remote: std::net::SocketAddr = join_host_port(host, port)
|
||||
.parse()
|
||||
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
|
||||
let (ep, observed) = endpoint::client_pinned_with_identity(
|
||||
pin,
|
||||
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||
);
|
||||
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let conn = ep
|
||||
.connect(remote, "punktfunk")
|
||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
||||
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
||||
let fp_mismatch =
|
||||
pin.is_some() && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||
if fp_mismatch {
|
||||
PunktfunkError::Crypto
|
||||
} else {
|
||||
PunktfunkError::Io(std::io::Error::other(e.to_string()))
|
||||
}
|
||||
})?;
|
||||
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||
// The rest of the handshake runs in an inner future so a failure can consult
|
||||
// `conn.close_reason()`: a host that turned us away with a typed application close
|
||||
// (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
|
||||
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
|
||||
// read produces — the difference between "not accepted" and the actual cause.
|
||||
let handshake = async {
|
||||
let (mut send, recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
// Frame every read on this stream through the resumable reader: the control loop
|
||||
// below drives it from a `select!` arm and `clock_sync` wraps it in a timeout, and a
|
||||
// partial frame lost to either would misalign the stream for the whole session.
|
||||
let mut recv = io::MsgReader::new(recv);
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: crate::WIRE_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||
name: None,
|
||||
// Library id to launch this session, if the embedder asked for one.
|
||||
launch: launch.clone(),
|
||||
// The embedder's decode/present caps (e.g. the Windows client advertises
|
||||
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
|
||||
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
|
||||
// bit only asks the host for observability datagrams (never changes the encode).
|
||||
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||
// video frame indexes.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||
// resolves the emitted codec from these and reports it in `Welcome::codec`.
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
// The client display's HDR volume → the host's virtual-display EDID (host apps
|
||||
// tone-map to the client's real panel). `None` = unknown/SDR.
|
||||
display_hdr,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome = Welcome::decode(&recv.read_msg().await?)?;
|
||||
if welcome.compositor != CompositorPref::Auto {
|
||||
tracing::info!(
|
||||
compositor = welcome.compositor.as_str(),
|
||||
"host resolved compositor"
|
||||
);
|
||||
}
|
||||
if welcome.gamepad != GamepadPref::Auto {
|
||||
tracing::info!(
|
||||
gamepad = welcome.gamepad.as_str(),
|
||||
"host resolved gamepad backend"
|
||||
);
|
||||
}
|
||||
|
||||
// Reserve our data-plane port, then start the host.
|
||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
||||
let udp_port = probe.local_addr()?.port();
|
||||
drop(probe);
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Start {
|
||||
client_udp_port: udp_port,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Wall-clock skew handshake on the control stream (before the session's control task takes
|
||||
// it): align our clock to the host's so the embedder can express receive/present instants in
|
||||
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
||||
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
||||
let (clock_offset_ns, clock_rtt_ns) =
|
||||
match crate::quic::clock_sync(&mut send, &mut recv).await {
|
||||
Some(skew) => {
|
||||
tracing::info!(
|
||||
offset_ns = skew.offset_ns,
|
||||
rtt_us = skew.rtt_ns / 1000,
|
||||
rounds = skew.rounds,
|
||||
"clock skew estimated (host-client)"
|
||||
);
|
||||
(skew.offset_ns, Some(skew.rtt_ns))
|
||||
}
|
||||
None => (0, None),
|
||||
};
|
||||
|
||||
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
||||
let transport =
|
||||
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
|
||||
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
|
||||
// to open the path first). Stops with the session via the shared shutdown flag.
|
||||
if let Ok(sock) = transport.try_clone_socket() {
|
||||
crate::transport::spawn_data_punch(sock, shutdown.clone());
|
||||
}
|
||||
let mut session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||
// PyroWave sessions opt into partial delivery (plan §4.4): an aged-out lossy
|
||||
// frame arrives as blocks-with-holes instead of vanishing — the all-intra codec
|
||||
// renders it as one frame of localized blur, strictly better than a freeze.
|
||||
if welcome.codec == crate::quic::CODEC_PYROWAVE {
|
||||
session.set_deliver_partial_frames(true);
|
||||
}
|
||||
Ok::<_, PunktfunkError>((
|
||||
session,
|
||||
send,
|
||||
recv,
|
||||
Negotiated {
|
||||
mode: welcome.mode,
|
||||
compositor: welcome.compositor,
|
||||
gamepad: welcome.gamepad,
|
||||
host_fingerprint: fingerprint,
|
||||
bitrate_kbps: welcome.bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
clock_rtt_ns,
|
||||
bit_depth: welcome.bit_depth,
|
||||
color: welcome.color,
|
||||
chroma_format: welcome.chroma_format,
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
};
|
||||
match handshake.await {
|
||||
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
|
||||
conn,
|
||||
session,
|
||||
ctrl_send: send,
|
||||
ctrl_recv: recv,
|
||||
negotiated,
|
||||
host_caps,
|
||||
}),
|
||||
Err(e) => Err(match reject_from_close(&conn) {
|
||||
Some(r) => PunktfunkError::Rejected(r),
|
||||
None => e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
//! Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
||||
//! HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
||||
//! folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
|
||||
//! datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
|
||||
//! per-transition event would corrupt held pad state until the *next* change — a held trigger
|
||||
//! stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
|
||||
//! reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
|
||||
//! interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
|
||||
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||
//! getting the legacy per-transition gamepad events.
|
||||
|
||||
use super::super::*;
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn run(
|
||||
conn: quinn::Connection,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
gamepad_snapshots: bool,
|
||||
) {
|
||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
// Per-pad wrapping seq that PERSISTS across a pad's remove/re-add on the same index (the
|
||||
// snapshot itself is cleared to `None` on removal). A removal takes `seq[idx] + 1` so it
|
||||
// supersedes every prior snapshot; the re-added pad's first snapshot takes the next value
|
||||
// after that, so the host's seq gate accepts it instead of rejecting a restarted-at-0 seq.
|
||||
let mut seq: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// Re-sends of a removal still owed on refresh ticks (the removal rides the lossy datagram
|
||||
// plane; a single lost one would silently strand a ghost pad on the host — the exact bug
|
||||
// the removal fixes). Mirrors the host's rumble stop burst: a few time-spread re-sends,
|
||||
// each with a fresh (higher) seq, and canceled the moment the pad is driven again.
|
||||
const REMOVE_RESENDS: u8 = 2;
|
||||
let mut remove_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// Per-pad declared controller kind ([`GamepadArrival`]) + its owed re-sends: the host needs
|
||||
// the kind before the pad's first frame to build a matching virtual device (mixed types), so
|
||||
// like the removal it rides the lossy plane with a small time-spread re-send burst.
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = input_rx.recv() => {
|
||||
let Some(ev) = ev else { break };
|
||||
let idx = ev.flags as usize;
|
||||
if gamepad_snapshots
|
||||
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
|
||||
&& idx < MAX_PADS
|
||||
{
|
||||
// The pad is being driven — cancel any owed removal (a re-plug on this
|
||||
// index; its fresh snapshot seq already supersedes the removal's).
|
||||
remove_owed[idx] = 0;
|
||||
let snap = pads[idx].get_or_insert(GamepadSnapshot {
|
||||
pad: idx as u8,
|
||||
..Default::default()
|
||||
});
|
||||
// Unknown axis ids don't send (the host's legacy fold drops them too).
|
||||
if snap.fold(&ev) {
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
snap.seq = seq[idx];
|
||||
let _ = conn
|
||||
.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadRemove && idx < MAX_PADS {
|
||||
// Stop refreshing the pad and forward a seq-stamped removal (in the shared
|
||||
// seq space) so the host tears its virtual device down and no reordered
|
||||
// snapshot can resurrect it; arm the re-send burst against datagram loss.
|
||||
// Drop any owed kind declaration too — a re-plug on this index sends its own.
|
||||
pads[idx] = None;
|
||||
arrival[idx] = None;
|
||||
arrival_owed[idx] = 0;
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
remove_owed[idx] = REMOVE_RESENDS;
|
||||
let rem = crate::input::InputEvent {
|
||||
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||
..ev
|
||||
};
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
for idx in 0..MAX_PADS {
|
||||
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||
if arrival_owed[idx] > 0 {
|
||||
if let Some(kind) = arrival[idx] {
|
||||
arrival_owed[idx] -= 1;
|
||||
let arr = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadArrival,
|
||||
_pad: [0; 3],
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
};
|
||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
arrival_owed[idx] = 0;
|
||||
}
|
||||
}
|
||||
if let Some(snap) = pads[idx].as_mut() {
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
snap.seq = seq[idx];
|
||||
let _ = conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
} else if remove_owed[idx] > 0 {
|
||||
// Idempotent removal re-send with a fresh seq (the host drops it as a
|
||||
// no-op once the pad is already gone, but a re-plug's later snapshot
|
||||
// still wins by seq).
|
||||
remove_owed[idx] -= 1;
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
let rem = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadRemove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||
};
|
||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user